Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 19 additions & 5 deletions assets/js/components/Energyflow/Energyflow.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,23 +148,37 @@ 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({});
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({});
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({});
Expand All @@ -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),
]),
Expand All @@ -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({});
Expand Down
35 changes: 12 additions & 23 deletions assets/js/components/Energyflow/Energyflow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -350,6 +350,7 @@ import { defineComponent, type PropType } from "vue";
import {
SMART_COST_TYPE,
type Battery,
type BatteryForecastPoint,
type Meter,
type CURRENCY,
type Forecast,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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>
Expand Down
33 changes: 30 additions & 3 deletions assets/js/components/Energyflow/ForecastMessage.vue
Original file line number Diff line number Diff line change
@@ -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>
Expand Down
10 changes: 8 additions & 2 deletions assets/js/types/evcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,8 +589,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 {
Expand Down
89 changes: 57 additions & 32 deletions core/site_optimizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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
}

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
// batteryForecastSocExtremes returns the highest and lowest aggregate SOC
Comment thread
andig marked this conversation as resolved.
// 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
}

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

// 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 high, low
}

func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDuration time.Duration, grid api.Rates) (optimizer.BatteryConfig, batteryDetail) {
Expand Down
Loading
Loading