From 6dd6c00a3f8cef13aebd37ee5341296ffc631646 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 2 May 2026 11:20:43 +0200 Subject: [PATCH 01/10] Optimizer: expose forecasted highest/lowest battery SOC Replaces the boolean Full/Empty timestamps with two BatteryForecastPoint entries (Highest/Lowest) carrying the predicted SOC, time and a Limit flag indicating whether the configured SMax/SMin boundary is reached (within 1% tolerance). The frontend uses Limit to render either "full/empty {time}" or "{soc}% {time}" so the next high/low point remains visible even when the battery is not forecasted to reach a boundary. Fixes #29004 --- .../Energyflow/Energyflow.stories.ts | 24 +++- .../js/components/Energyflow/Energyflow.vue | 45 +++--- assets/js/types/evcc.ts | 10 +- core/site_optimizer.go | 95 ++++++++----- core/site_optimizer_test.go | 131 ++++++++++++------ core/types/types.go | 13 +- i18n/de.json | 1 + i18n/en.json | 1 + 8 files changed, 216 insertions(+), 104 deletions(-) diff --git a/assets/js/components/Energyflow/Energyflow.stories.ts b/assets/js/components/Energyflow/Energyflow.stories.ts index 34af8054461..195dd2a286b 100644 --- a/assets/js/components/Energyflow/Energyflow.stories.ts +++ b/assets/js/components/Energyflow/Energyflow.stories.ts @@ -100,7 +100,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({}); @@ -108,7 +108,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({}); @@ -116,7 +116,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), limit: false }, + }), } as any; export const BatteryForecastMulti = Template.bind({}); @@ -125,7 +139,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), ]), @@ -139,7 +153,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({}); diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 49fd962455d..8a6b8bd425f 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -131,13 +131,13 @@ #subline >
- +
  @@ -272,13 +272,13 @@ #subline >
- +
  @@ -583,14 +583,14 @@ export default defineComponent({ consumers() { return [...this.aux, ...this.ext]; }, - batteryForecastFull(): string | undefined { - return this.fmtForecast(this.battery?.forecast, true); + batteryForecastHighest(): string | undefined { + return this.fmtForecastPoint(this.battery?.forecast?.highest, true); }, - batteryForecastEmpty(): string | undefined { - return this.fmtForecast(this.battery?.forecast, false); + batteryForecastLowest(): string | undefined { + return this.fmtForecastPoint(this.battery?.forecast?.lowest, false); }, batteryForecastExists(): boolean { - return !!(this.batteryForecastEmpty || this.batteryForecastFull); + return !!(this.batteryForecastHighest || this.batteryForecastLowest); }, }, watch: { @@ -687,17 +687,20 @@ 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 + fmtForecastPoint( + point: { soc: number; time: string; limit?: boolean } | undefined, + high: 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 }); + if (!point) return undefined; + const time = this.fmtAbsoluteDate(new Date(point.time)); + if (point.limit) { + const key = high + ? "main.energyflow.batteryForecastFull" + : "main.energyflow.batteryForecastEmpty"; + return this.$t(key, { time }); + } + const soc = `${Math.round(point.soc)}%`; + return this.$t("main.energyflow.batteryForecastSoc", { soc, time }); }, }, }); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 895b7852732..de7e4e7ffed 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -585,8 +585,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 { diff --git a/core/site_optimizer.go b/core/site_optimizer.go index ac4a658ceec..40175d7c4ad 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -292,51 +292,84 @@ 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) + now := time.Now().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(time.Now()) { + 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 +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. SOC values +// within 1% of capacity from SMax/SMin are treated as having reached the +// boundary. +// Returns nil for either point when no home battery is present. +func batteryForecastSocExtremes(req []optimizer.BatteryConfig, resp []optimizer.BatteryResult) (high, low *batteryForecastSlot) { + var totalCapacity, totalSMax, totalSMin float32 + for _, b := range req { + if b.SCapacity > 0 { + totalCapacity += b.SCapacity + totalSMax += b.SMax + totalSMin += b.SMin } - return -1 + } + if totalCapacity == 0 || len(resp) == 0 { + return } - 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 - }) + tolerance := 0.01 * totalCapacity + + for i := range resp[0].StateOfCharge { + var sum float32 + for batIdx := range req { + if req[batIdx].SCapacity > 0 { + sum += resp[batIdx].StateOfCharge[i] + } + } + soc := float64(sum/totalCapacity) * 100 + fullReached := totalSMax > 0 && sum >= totalSMax-tolerance + emptyReached := sum <= totalSMin+tolerance + + // 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 fullSlot, emptySlot + return } func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDuration time.Duration, grid api.Rates) (optimizer.BatteryConfig, batteryDetail) { diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 42f17b5215b..7919df45a0e 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -46,67 +46,112 @@ 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}, + }, + { + "within 1% of SMax counts as full", + []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}}, + [][]float32{{500, 991, 800}}, + &batteryForecastSlot{slot: 1, soc: 99.1, limit: true}, + &batteryForecastSlot{slot: 0, soc: 50, limit: false}, + }, + { + "within 1% of SMin counts as empty", + []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 900, SMin: 100}}, + [][]float32{{500, 109, 800}}, + &batteryForecastSlot{slot: 2, soc: 80, limit: false}, + &batteryForecastSlot{slot: 1, soc: 10.9, limit: true}, }, } { 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") + } }) } } diff --git a/core/types/types.go b/core/types/types.go index 5c38828f0eb..ad233bd824d 100644 --- a/core/types/types.go +++ b/core/types/types.go @@ -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) diff --git a/i18n/de.json b/i18n/de.json index c528319b9d9..e79b8a5621a 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1156,6 +1156,7 @@ "batteryDischarge": "Batterie entladen", "batteryForecastEmpty": "leer {time}", "batteryForecastFull": "voll {time}", + "batteryForecastSoc": "{soc} {time}", "batteryGridChargeActive": "Netzladen: aktiv", "batteryGridChargeLimit": "Netzladen: wenn", "batteryHold": "Batterie (gesperrt)", diff --git a/i18n/en.json b/i18n/en.json index 3fb0694d8c3..fd4f1c0bd69 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1156,6 +1156,7 @@ "batteryDischarge": "Battery discharging", "batteryForecastEmpty": "empty {time}", "batteryForecastFull": "full {time}", + "batteryForecastSoc": "{soc} {time}", "batteryGridChargeActive": "Grid charging: active", "batteryGridChargeLimit": "Grid charging: when", "batteryHold": "Battery (locked)", From e553bacd98e38af06d63f9183b1e8196ca114cb1 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 2 May 2026 11:26:40 +0200 Subject: [PATCH 02/10] Optimizer: drop 1% tolerance for SOC limit detection --- core/site_optimizer.go | 10 +++------- core/site_optimizer_test.go | 13 +++---------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 40175d7c4ad..9de6e35d80c 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -329,9 +329,7 @@ type batteryForecastSlot struct { // 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. SOC values -// within 1% of capacity from SMax/SMin are treated as having reached the -// boundary. +// 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) (high, low *batteryForecastSlot) { var totalCapacity, totalSMax, totalSMin float32 @@ -346,8 +344,6 @@ func batteryForecastSocExtremes(req []optimizer.BatteryConfig, resp []optimizer. return } - tolerance := 0.01 * totalCapacity - for i := range resp[0].StateOfCharge { var sum float32 for batIdx := range req { @@ -356,8 +352,8 @@ func batteryForecastSocExtremes(req []optimizer.BatteryConfig, resp []optimizer. } } soc := float64(sum/totalCapacity) * 100 - fullReached := totalSMax > 0 && sum >= totalSMax-tolerance - emptyReached := sum <= totalSMin+tolerance + fullReached := totalSMax > 0 && sum >= totalSMax + emptyReached := sum <= totalSMin // first slot at SMax wins for highest if high == nil || (!high.limit && (soc > high.soc || fullReached)) { diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 7919df45a0e..9c50919853c 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -114,19 +114,12 @@ func TestBatteryForecastSocExtremes(t *testing.T) { &batteryForecastSlot{slot: 2, soc: 50, limit: false}, }, { - "within 1% of SMax counts as full", + "near SMax is not full", []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}}, - [][]float32{{500, 991, 800}}, - &batteryForecastSlot{slot: 1, soc: 99.1, limit: true}, + [][]float32{{500, 999, 800}}, + &batteryForecastSlot{slot: 1, soc: 99.9, limit: false}, &batteryForecastSlot{slot: 0, soc: 50, limit: false}, }, - { - "within 1% of SMin counts as empty", - []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 900, SMin: 100}}, - [][]float32{{500, 109, 800}}, - &batteryForecastSlot{slot: 2, soc: 80, limit: false}, - &batteryForecastSlot{slot: 1, soc: 10.9, limit: true}, - }, } { t.Run(tc.name, func(t *testing.T) { resp := make([]optimizer.BatteryResult, len(tc.soc)) From 8d736e4be6d04f4b26e9f041df9d8711a0d28f9e Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 2 May 2026 11:28:56 +0200 Subject: [PATCH 03/10] Address PR feedback: consistent time.Now and shared BatteryForecastPoint type --- assets/js/components/Energyflow/Energyflow.vue | 3 ++- core/site_optimizer.go | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 8a6b8bd425f..5d0aa3acc8d 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -350,6 +350,7 @@ import { defineComponent, type PropType } from "vue"; import { SMART_COST_TYPE, type Battery, + type BatteryForecastPoint, type Meter, type CURRENCY, type Forecast, @@ -688,7 +689,7 @@ export default defineComponent({ return `${this.$t("config.devices.consumer")} #${index + 1}`; }, fmtForecastPoint( - point: { soc: number; time: string; limit?: boolean } | undefined, + point: BatteryForecastPoint | undefined, high: boolean ): string | undefined { if (!point) return undefined; diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 9de6e35d80c..e284e1b7e2d 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -297,13 +297,14 @@ func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp [ return nil } - now := time.Now().Round(tariff.SlotDuration) + cutoff := time.Now() + now := cutoff.Round(tariff.SlotDuration) point := func(p *batteryForecastSlot) *types.BatteryForecastPoint { if p == nil { return nil } ts := now.Add(time.Duration(p.slot) * tariff.SlotDuration) - if !ts.After(time.Now()) { + if !ts.After(cutoff) { return nil } return &types.BatteryForecastPoint{Soc: p.soc, Time: ts, Limit: p.limit} From d547b00d765f180f54479ea84dafbe756c1a6f3f Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 2 May 2026 11:36:33 +0200 Subject: [PATCH 04/10] Optimizer: encode forecast limit as 'full'/'empty' string Replaces the boolean BatteryForecastPoint.Limit with a typed string so the JSON payload is self-describing and the frontend formatter no longer needs a separate 'high' parameter to map the flag onto the right label. --- .../components/Energyflow/Energyflow.stories.ts | 16 ++++++++-------- assets/js/components/Energyflow/Energyflow.vue | 17 +++++------------ assets/js/types/evcc.ts | 2 +- core/site_optimizer.go | 12 ++++++++---- core/types/types.go | 11 ++++++++--- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/assets/js/components/Energyflow/Energyflow.stories.ts b/assets/js/components/Energyflow/Energyflow.stories.ts index 195dd2a286b..6cc507d7552 100644 --- a/assets/js/components/Energyflow/Energyflow.stories.ts +++ b/assets/js/components/Energyflow/Energyflow.stories.ts @@ -100,7 +100,7 @@ BatteryForecastDischarging.args = { ...batteryBase, gridPower: 500, homePower: 1800, - battery: bat(1300, 62, { lowest: { soc: 0, time: hoursFromNow(0.4), limit: true } }), + battery: bat(1300, 62, { lowest: { soc: 0, time: hoursFromNow(0.4), limit: "empty" } }), } as any; export const BatteryForecastCharging = Template.bind({}); @@ -108,7 +108,7 @@ BatteryForecastCharging.args = { ...batteryBase, pvPower: 6000, gridPower: -1000, - battery: bat(-4200, 45, { highest: { soc: 100, time: hoursFromNow(2.5), limit: true } }), + battery: bat(-4200, 45, { highest: { soc: 100, time: hoursFromNow(2.5), limit: "full" } }), } as any; export const BatteryForecastBoth = Template.bind({}); @@ -117,8 +117,8 @@ BatteryForecastBoth.args = { pvPower: 3000, gridPower: -500, battery: bat(-1700, 70, { - highest: { soc: 100, time: hoursFromNow(2), limit: true }, - lowest: { soc: 0, time: hoursFromNow(36), limit: true }, + highest: { soc: 100, time: hoursFromNow(2), limit: "full" }, + lowest: { soc: 0, time: hoursFromNow(36), limit: "empty" }, }), } as any; @@ -128,8 +128,8 @@ BatteryForecastSocExtremes.args = { gridPower: 200, homePower: 1500, battery: bat(1300, 95, { - highest: { soc: 100, time: hoursFromNow(20), limit: true }, - lowest: { soc: 34, time: hoursFromNow(8), limit: false }, + highest: { soc: 100, time: hoursFromNow(20), limit: "full" }, + lowest: { soc: 34, time: hoursFromNow(8) }, }), } as any; @@ -139,7 +139,7 @@ BatteryForecastMulti.args = { pvPower: 8000, gridPower: -1000, homePower: 1000, - battery: bat(-6000, 40, { highest: { soc: 100, time: hoursFromNow(26), limit: true } }, [ + battery: bat(-6000, 40, { highest: { soc: 100, time: hoursFromNow(26), limit: "full" } }, [ dev("Powerwall", -3500, 35), dev("BYD", -2500, 47), ]), @@ -153,7 +153,7 @@ BatteryForecastGridChargeLimit.args = { batteryGridChargeLimit: 0.15, smartCostType: "price", currency: CURRENCY.EUR, - battery: bat(-3700, 50, { highest: { soc: 100, time: hoursFromNow(1.5), limit: true } }), + battery: bat(-3700, 50, { highest: { soc: 100, time: hoursFromNow(1.5), limit: "full" } }), } as any; export const BatteryAndGrid = Template.bind({}); diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 5d0aa3acc8d..7d815772880 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -585,10 +585,10 @@ export default defineComponent({ return [...this.aux, ...this.ext]; }, batteryForecastHighest(): string | undefined { - return this.fmtForecastPoint(this.battery?.forecast?.highest, true); + return this.fmtForecastPoint(this.battery?.forecast?.highest); }, batteryForecastLowest(): string | undefined { - return this.fmtForecastPoint(this.battery?.forecast?.lowest, false); + return this.fmtForecastPoint(this.battery?.forecast?.lowest); }, batteryForecastExists(): boolean { return !!(this.batteryForecastHighest || this.batteryForecastLowest); @@ -688,18 +688,11 @@ export default defineComponent({ genericConsumerTitle(index: number) { return `${this.$t("config.devices.consumer")} #${index + 1}`; }, - fmtForecastPoint( - point: BatteryForecastPoint | undefined, - high: boolean - ): string | undefined { + fmtForecastPoint(point: BatteryForecastPoint | undefined): string | undefined { if (!point) return undefined; const time = this.fmtAbsoluteDate(new Date(point.time)); - if (point.limit) { - const key = high - ? "main.energyflow.batteryForecastFull" - : "main.energyflow.batteryForecastEmpty"; - return this.$t(key, { time }); - } + if (point.limit === "full") return this.$t("main.energyflow.batteryForecastFull", { time }); + if (point.limit === "empty") return this.$t("main.energyflow.batteryForecastEmpty", { time }); const soc = `${Math.round(point.soc)}%`; return this.$t("main.energyflow.batteryForecastSoc", { soc, time }); }, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index de7e4e7ffed..16bd8efae8a 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -592,7 +592,7 @@ export interface BatteryForecast { export interface BatteryForecastPoint { soc: number; // percent time: string; // ISO 8601 datetime - limit?: boolean; // true when SMax (highest) or SMin (lowest) boundary reached + limit?: "full" | "empty"; // SMax/SMin boundary reached } export interface Battery { diff --git a/core/site_optimizer.go b/core/site_optimizer.go index e284e1b7e2d..b46e362d846 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -299,7 +299,7 @@ func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp [ cutoff := time.Now() now := cutoff.Round(tariff.SlotDuration) - point := func(p *batteryForecastSlot) *types.BatteryForecastPoint { + point := func(p *batteryForecastSlot, label string) *types.BatteryForecastPoint { if p == nil { return nil } @@ -307,12 +307,16 @@ func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp [ if !ts.After(cutoff) { return nil } - return &types.BatteryForecastPoint{Soc: p.soc, Time: ts, Limit: p.limit} + pt := &types.BatteryForecastPoint{Soc: p.soc, Time: ts} + if p.limit { + pt.Limit = label + } + return pt } res := types.BatteryForecast{ - Highest: point(high), - Lowest: point(low), + Highest: point(high, types.BatteryForecastLimitFull), + Lowest: point(low, types.BatteryForecastLimitEmpty), } if res.Highest == nil && res.Lowest == nil { return nil diff --git a/core/types/types.go b/core/types/types.go index ad233bd824d..e35f0646e3b 100644 --- a/core/types/types.go +++ b/core/types/types.go @@ -26,14 +26,19 @@ type BatteryForecast struct { } // 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. +// Limit is "full" or "empty" when the configured SMax/SMin boundary is reached +// at this point, otherwise empty. type BatteryForecastPoint struct { Soc float64 `json:"soc"` Time time.Time `json:"time"` - Limit bool `json:"limit,omitempty"` + Limit string `json:"limit,omitempty"` } +const ( + BatteryForecastLimitFull = "full" + BatteryForecastLimitEmpty = "empty" +) + var _ api.TitleDescriber = (*Measurement)(nil) // GetTitle implements api.TitleDescriber interface for InfluxDB tagging From 1293d0272d38075fd353cd3a8a1c0015d3981c76 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 2 May 2026 13:52:17 +0200 Subject: [PATCH 05/10] Prettier formatting --- assets/js/components/Energyflow/Energyflow.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 7d815772880..20b093c0b78 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -691,8 +691,10 @@ export default defineComponent({ fmtForecastPoint(point: BatteryForecastPoint | undefined): string | undefined { if (!point) return undefined; const time = this.fmtAbsoluteDate(new Date(point.time)); - if (point.limit === "full") return this.$t("main.energyflow.batteryForecastFull", { time }); - if (point.limit === "empty") return this.$t("main.energyflow.batteryForecastEmpty", { time }); + if (point.limit === "full") + return this.$t("main.energyflow.batteryForecastFull", { time }); + if (point.limit === "empty") + return this.$t("main.energyflow.batteryForecastEmpty", { time }); const soc = `${Math.round(point.soc)}%`; return this.$t("main.energyflow.batteryForecastSoc", { soc, time }); }, From aaf369c9f11fac5c5e2be46a937311be5a7e3ce2 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 2 May 2026 14:09:13 +0200 Subject: [PATCH 06/10] Optimizer: drop named returns and use lo.SumBy in forecast extremes --- core/site_optimizer.go | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index b46e362d846..48659319b86 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -336,26 +336,21 @@ type batteryForecastSlot struct { // 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) (high, low *batteryForecastSlot) { - var totalCapacity, totalSMax, totalSMin float32 - for _, b := range req { - if b.SCapacity > 0 { - totalCapacity += b.SCapacity - totalSMax += b.SMax - totalSMin += b.SMin - } - } - if totalCapacity == 0 || len(resp) == 0 { - return +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 } - for i := range resp[0].StateOfCharge { - var sum float32 - for batIdx := range req { - if req[batIdx].SCapacity > 0 { - sum += resp[batIdx].StateOfCharge[i] - } - } + 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 }) + + 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 @@ -370,7 +365,7 @@ func batteryForecastSocExtremes(req []optimizer.BatteryConfig, resp []optimizer. } } - return + return high, low } func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDuration time.Duration, grid api.Rates) (optimizer.BatteryConfig, batteryDetail) { From 4f48397b253fb44ab6fc256a22cf0fae55a13109 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 3 May 2026 11:15:17 +0200 Subject: [PATCH 07/10] Optimizer: revert Limit to bool Per review feedback, swaps the 'full'/'empty' string back to a plain bool. The frontend already knows whether it is rendering the highest or lowest point, so it passes that as a flag to fmtForecastPoint and selects the right i18n key from there. --- .../Energyflow/Energyflow.stories.ts | 14 +++++++------- .../js/components/Energyflow/Energyflow.vue | 19 ++++++++++++------- assets/js/types/evcc.ts | 2 +- core/site_optimizer.go | 12 ++++-------- core/types/types.go | 11 +++-------- 5 files changed, 27 insertions(+), 31 deletions(-) diff --git a/assets/js/components/Energyflow/Energyflow.stories.ts b/assets/js/components/Energyflow/Energyflow.stories.ts index 6cc507d7552..f7a4558cd54 100644 --- a/assets/js/components/Energyflow/Energyflow.stories.ts +++ b/assets/js/components/Energyflow/Energyflow.stories.ts @@ -100,7 +100,7 @@ BatteryForecastDischarging.args = { ...batteryBase, gridPower: 500, homePower: 1800, - battery: bat(1300, 62, { lowest: { soc: 0, time: hoursFromNow(0.4), limit: "empty" } }), + battery: bat(1300, 62, { lowest: { soc: 0, time: hoursFromNow(0.4), limit: true } }), } as any; export const BatteryForecastCharging = Template.bind({}); @@ -108,7 +108,7 @@ BatteryForecastCharging.args = { ...batteryBase, pvPower: 6000, gridPower: -1000, - battery: bat(-4200, 45, { highest: { soc: 100, time: hoursFromNow(2.5), limit: "full" } }), + battery: bat(-4200, 45, { highest: { soc: 100, time: hoursFromNow(2.5), limit: true } }), } as any; export const BatteryForecastBoth = Template.bind({}); @@ -117,8 +117,8 @@ BatteryForecastBoth.args = { pvPower: 3000, gridPower: -500, battery: bat(-1700, 70, { - highest: { soc: 100, time: hoursFromNow(2), limit: "full" }, - lowest: { soc: 0, time: hoursFromNow(36), limit: "empty" }, + highest: { soc: 100, time: hoursFromNow(2), limit: true }, + lowest: { soc: 0, time: hoursFromNow(36), limit: true }, }), } as any; @@ -128,7 +128,7 @@ BatteryForecastSocExtremes.args = { gridPower: 200, homePower: 1500, battery: bat(1300, 95, { - highest: { soc: 100, time: hoursFromNow(20), limit: "full" }, + highest: { soc: 100, time: hoursFromNow(20), limit: true }, lowest: { soc: 34, time: hoursFromNow(8) }, }), } as any; @@ -139,7 +139,7 @@ BatteryForecastMulti.args = { pvPower: 8000, gridPower: -1000, homePower: 1000, - battery: bat(-6000, 40, { highest: { soc: 100, time: hoursFromNow(26), limit: "full" } }, [ + battery: bat(-6000, 40, { highest: { soc: 100, time: hoursFromNow(26), limit: true } }, [ dev("Powerwall", -3500, 35), dev("BYD", -2500, 47), ]), @@ -153,7 +153,7 @@ BatteryForecastGridChargeLimit.args = { batteryGridChargeLimit: 0.15, smartCostType: "price", currency: CURRENCY.EUR, - battery: bat(-3700, 50, { highest: { soc: 100, time: hoursFromNow(1.5), limit: "full" } }), + battery: bat(-3700, 50, { highest: { soc: 100, time: hoursFromNow(1.5), limit: true } }), } as any; export const BatteryAndGrid = Template.bind({}); diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 20b093c0b78..5d0aa3acc8d 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -585,10 +585,10 @@ export default defineComponent({ return [...this.aux, ...this.ext]; }, batteryForecastHighest(): string | undefined { - return this.fmtForecastPoint(this.battery?.forecast?.highest); + return this.fmtForecastPoint(this.battery?.forecast?.highest, true); }, batteryForecastLowest(): string | undefined { - return this.fmtForecastPoint(this.battery?.forecast?.lowest); + return this.fmtForecastPoint(this.battery?.forecast?.lowest, false); }, batteryForecastExists(): boolean { return !!(this.batteryForecastHighest || this.batteryForecastLowest); @@ -688,13 +688,18 @@ export default defineComponent({ genericConsumerTitle(index: number) { return `${this.$t("config.devices.consumer")} #${index + 1}`; }, - fmtForecastPoint(point: BatteryForecastPoint | undefined): string | undefined { + fmtForecastPoint( + point: BatteryForecastPoint | undefined, + high: boolean + ): string | undefined { if (!point) return undefined; const time = this.fmtAbsoluteDate(new Date(point.time)); - if (point.limit === "full") - return this.$t("main.energyflow.batteryForecastFull", { time }); - if (point.limit === "empty") - return this.$t("main.energyflow.batteryForecastEmpty", { time }); + if (point.limit) { + const key = high + ? "main.energyflow.batteryForecastFull" + : "main.energyflow.batteryForecastEmpty"; + return this.$t(key, { time }); + } const soc = `${Math.round(point.soc)}%`; return this.$t("main.energyflow.batteryForecastSoc", { soc, time }); }, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 16bd8efae8a..de7e4e7ffed 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -592,7 +592,7 @@ export interface BatteryForecast { export interface BatteryForecastPoint { soc: number; // percent time: string; // ISO 8601 datetime - limit?: "full" | "empty"; // SMax/SMin boundary reached + limit?: boolean; // true when SMax (highest) or SMin (lowest) boundary reached } export interface Battery { diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 48659319b86..cfa4cf00fa7 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -299,7 +299,7 @@ func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp [ cutoff := time.Now() now := cutoff.Round(tariff.SlotDuration) - point := func(p *batteryForecastSlot, label string) *types.BatteryForecastPoint { + point := func(p *batteryForecastSlot) *types.BatteryForecastPoint { if p == nil { return nil } @@ -307,16 +307,12 @@ func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp [ if !ts.After(cutoff) { return nil } - pt := &types.BatteryForecastPoint{Soc: p.soc, Time: ts} - if p.limit { - pt.Limit = label - } - return pt + return &types.BatteryForecastPoint{Soc: p.soc, Time: ts, Limit: p.limit} } res := types.BatteryForecast{ - Highest: point(high, types.BatteryForecastLimitFull), - Lowest: point(low, types.BatteryForecastLimitEmpty), + Highest: point(high), + Lowest: point(low), } if res.Highest == nil && res.Lowest == nil { return nil diff --git a/core/types/types.go b/core/types/types.go index e35f0646e3b..ad233bd824d 100644 --- a/core/types/types.go +++ b/core/types/types.go @@ -26,19 +26,14 @@ type BatteryForecast struct { } // BatteryForecastPoint describes an extreme SOC point in the battery forecast. -// Limit is "full" or "empty" when the configured SMax/SMin boundary is reached -// at this point, otherwise empty. +// 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 string `json:"limit,omitempty"` + Limit bool `json:"limit,omitempty"` } -const ( - BatteryForecastLimitFull = "full" - BatteryForecastLimitEmpty = "empty" -) - var _ api.TitleDescriber = (*Measurement)(nil) // GetTitle implements api.TitleDescriber interface for InfluxDB tagging From 5dcf0f0dbe32fa0edc187da7d429bbabe0b93018 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 09:57:29 +0000 Subject: [PATCH 08/10] Use fmtPercentage for localized SOC formatting in battery forecast Agent-Logs-Url: https://github.com/evcc-io/evcc/sessions/2fed810b-6777-480a-b514-5276c1ce1661 Co-authored-by: andig <184815+andig@users.noreply.github.com> --- assets/js/components/Energyflow/Energyflow.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 5d0aa3acc8d..1d2ac8d5b58 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -700,7 +700,7 @@ export default defineComponent({ : "main.energyflow.batteryForecastEmpty"; return this.$t(key, { time }); } - const soc = `${Math.round(point.soc)}%`; + const soc = this.fmtPercentage(point.soc); return this.$t("main.energyflow.batteryForecastSoc", { soc, time }); }, }, From 26239035b232e9cca52aeb8949b3036fd7c2a5f6 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 6 May 2026 09:51:10 +0200 Subject: [PATCH 09/10] improve commnuication --- .../js/components/Energyflow/Energyflow.vue | 27 ++++----------- .../components/Energyflow/ForecastMessage.vue | 33 +++++++++++++++++-- i18n/de.json | 7 ++-- i18n/en.json | 7 ++-- 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 1d2ac8d5b58..f2699c817e9 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -134,7 +134,7 @@ v-if="batteryForecastLowest" class="d-flex align-items-center mb-2" > - +
- +
diff --git a/assets/js/components/Energyflow/ForecastMessage.vue b/assets/js/components/Energyflow/ForecastMessage.vue index e2592843132..8e129985779 100644 --- a/assets/js/components/Energyflow/ForecastMessage.vue +++ b/assets/js/components/Energyflow/ForecastMessage.vue @@ -1,16 +1,43 @@ diff --git a/i18n/de.json b/i18n/de.json index e79b8a5621a..98edba4e650 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1154,9 +1154,10 @@ "battery": "Batterie", "batteryCharge": "Batterie laden", "batteryDischarge": "Batterie entladen", - "batteryForecastEmpty": "leer {time}", - "batteryForecastFull": "voll {time}", - "batteryForecastSoc": "{soc} {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)", diff --git a/i18n/en.json b/i18n/en.json index fd4f1c0bd69..1d99708992c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1154,9 +1154,10 @@ "battery": "Battery", "batteryCharge": "Battery charging", "batteryDischarge": "Battery discharging", - "batteryForecastEmpty": "empty {time}", - "batteryForecastFull": "full {time}", - "batteryForecastSoc": "{soc} {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)", From e7ce9e742a1761f9387601128070f271191eab4d Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 6 May 2026 09:58:37 +0200 Subject: [PATCH 10/10] i18n: no trailing ': ' --- AGENTS.md | 1 + assets/js/components/Energyflow/ForecastMessage.vue | 2 +- i18n/da.json | 2 +- i18n/de.json | 6 +++--- i18n/en.json | 6 +++--- i18n/fi.json | 2 +- i18n/fr.json | 2 +- i18n/it.json | 2 +- i18n/lb.json | 2 +- i18n/lt.json | 2 +- i18n/nl.json | 2 +- i18n/pl.json | 2 +- i18n/pt.json | 2 +- i18n/ro.json | 2 +- i18n/sv.json | 2 +- i18n/ta.json | 2 +- i18n/tr.json | 2 +- 17 files changed, 21 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f7a467b3a49..aaff844dc55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/assets/js/components/Energyflow/ForecastMessage.vue b/assets/js/components/Energyflow/ForecastMessage.vue index 8e129985779..fda166b879f 100644 --- a/assets/js/components/Energyflow/ForecastMessage.vue +++ b/assets/js/components/Energyflow/ForecastMessage.vue @@ -1,6 +1,6 @@ diff --git a/i18n/da.json b/i18n/da.json index 6ec225ed33c..efe7f9f49ec 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -1171,7 +1171,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", diff --git a/i18n/de.json b/i18n/de.json index 5634785cd85..a870aee1158 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1171,13 +1171,13 @@ "batteryDischarge": "Batterie entladen", "batteryForecastEmpty": "Leer ab {time}", "batteryForecastFull": "Voll ab {time}", - "batteryForecastNextHigh": "Nächstes Hoch: ", - "batteryForecastNextLow": "Nächstes Tief: ", + "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", diff --git a/i18n/en.json b/i18n/en.json index 1f095ad4f85..4f16c615584 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1171,13 +1171,13 @@ "batteryDischarge": "Battery discharging", "batteryForecastEmpty": "Empty from {time}", "batteryForecastFull": "Full from {time}", - "batteryForecastNextHigh": "Next high: ", - "batteryForecastNextLow": "Next low: ", + "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", diff --git a/i18n/fi.json b/i18n/fi.json index cf32811c0ff..f7ff675bf45 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -1170,7 +1170,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", diff --git a/i18n/fr.json b/i18n/fr.json index 40649f24611..53c283b2307 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1131,7 +1131,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": "Utilisation du réseau", "homePower": "Consommation", diff --git a/i18n/it.json b/i18n/it.json index 940edfa9ae5..6ff7be90e14 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1123,7 +1123,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", diff --git a/i18n/lb.json b/i18n/lb.json index 27f3c437bf8..403bbe97ba6 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -1130,7 +1130,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": "Verbrauch vum Stroumnetz", "homePower": "Verbrauch", diff --git a/i18n/lt.json b/i18n/lt.json index 09adec2915e..759fbe8daa0 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -1171,7 +1171,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", diff --git a/i18n/nl.json b/i18n/nl.json index 6e843cbf2c0..6252380f1a6 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -1160,7 +1160,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", diff --git a/i18n/pl.json b/i18n/pl.json index 40a3a7c5ac1..618cbd104b1 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -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", diff --git a/i18n/pt.json b/i18n/pt.json index 7f654842ace..1652967a719 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -1130,7 +1130,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": "Consumo da Rede", "homePower": "Consumo", diff --git a/i18n/ro.json b/i18n/ro.json index a6d6ab08bab..64414b5034d 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -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", diff --git a/i18n/sv.json b/i18n/sv.json index a274321e96b..e91d83198da 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -1172,7 +1172,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", diff --git a/i18n/ta.json b/i18n/ta.json index c2e32cb0373..8476ec20bd5 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -1002,7 +1002,7 @@ "batteryGridChargeLimit": "கிரிட் சார்சிங்: எப்போது", "batteryHold": "மின்கலம் (பூட்டபட்டது)", "batteryTooltip": "{energy} ({total}) இன் {soc}", - "forecast": "முன்னறிவிப்பு: ", + "forecast": "முன்னறிவிப்பு", "forecastTooltip": "முன்னறிவிப்பு: இன்று சூரிய விளைவாக்கம்", "gridImport": "கட்டம் பயன்பாடு", "homePower": "நுகர்வு", diff --git a/i18n/tr.json b/i18n/tr.json index 52f20a9c8ca..8bd426a37e4 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -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",