From 713abb1e7ee55e3d36c9c239a5e8c8b0c97c5c3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 05:36:39 +0000 Subject: [PATCH 1/5] Initial plan From 12394a48465fc027458ba23010e0ba8afaf4c3fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 05:54:48 +0000 Subject: [PATCH 2/5] Revert " remove applyBatteryGridChargeLimit" This reverts commit 7678e23c48e727aa80e1edab0df75fad7cb95d28. Co-authored-by: jeffborg <1595430+jeffborg@users.noreply.github.com> --- core/site_optimizer.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 1521a6d1d8a..07dcf326aba 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -758,6 +758,30 @@ func applySmartCostLimit(lp loadpoint.API, demand []float32, grid api.Rates, min return demand } +func (site *Site) applyBatteryGridChargeLimit(cMax float32, grid api.Rates, minLen int) []float32 { + limit := site.GetBatteryGridChargeLimit() + if limit == nil { + return nil + } + + maxLen := min(minLen, len(grid)) + + if hasAffordableSlots := slices.ContainsFunc(grid[:maxLen], func(r api.Rate) bool { + return r.Value <= *limit + }); !hasAffordableSlots { + return nil + } + + demand := make([]float32, minLen) + for i := range maxLen { + if grid[i].Value <= *limit { + demand[i] = float32(float64(cMax) / slotsPerHour) + } + } + + return demand +} + // apiError extracts error message from optimizer API response func apiError(resp *optimizer.PostOptimizeChargeScheduleResponse) error { var errObj *optimizer.Error From 2653e1eae7d406e9a0e49f02c84264a6754028ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 05:56:23 +0000 Subject: [PATCH 3/5] Revert "minor mods to optimizer for my use case" This reverts commit 33f0d3160ec27b39a3c5ffe81e6cf965dc603052. Co-authored-by: jeffborg <1595430+jeffborg@users.noreply.github.com> --- core/site_optimizer.go | 88 ++++++++++--------------------------- core/site_optimizer_test.go | 35 +-------------- 2 files changed, 26 insertions(+), 97 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 07dcf326aba..3debe1b4f13 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -53,8 +53,7 @@ func (r optimizerResult) MarshalBytes() ([]byte, error) { type batteryType string const ( - OPTIMIZER_URI = "https://optimizer.evcc.io" - plannerRateFallback = 20 + OPTIMIZER_URI = "https://optimizer.evcc.io" batteryTypeLoadpoint batteryType = "loadpoint" batteryTypeVehicle batteryType = "vehicle" @@ -120,13 +119,11 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { solarTariff := site.GetTariff(api.TariffUsageSolar) solar := currentRates(solarTariff) - planner := currentRates(site.GetTariff(api.TariffUsagePlanner)) + grid := currentRates(site.GetTariff(api.TariffUsageGrid)) feedIn := currentRates(site.GetTariff(api.TariffUsageFeedIn)) - minLen := len(feedIn) - if plannerLen := rateHorizonSlots(planner); plannerLen > 0 { - minLen = min(minLen, plannerLen) - } + minLen := lo.Min([]int{len(grid), len(feedIn)}) + // exclude empty solar forecast from minLen if solarTariff != nil && len(solar) > 0 { minLen = min(minLen, len(solar)) } @@ -139,21 +136,19 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { if expectedSlots := 8; minLen < expectedSlots { if solarTariff != nil { - return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (planner=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(planner), len(feedIn), len(solar)) + return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(grid), len(feedIn), len(solar)) } - return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (planner=%d, feedIn=%d)", minLen, expectedSlots, len(planner), len(feedIn)) + return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn)) } - grid := fillMissingRateSlots(planner, minLen, plannerRateFallback) - now := time.Now() dt := timeSteps(minLen, now) firstSlotDuration := time.Duration(dt[0]) * time.Second - site.log.DEBUG.Printf("optimizer: optimizing %d slots until %v: planner=%d, feedIn=%d, solar=%d, first slot: %v", + site.log.DEBUG.Printf("optimizer: optimizing %d slots until %v: grid=%d, feedIn=%d, solar=%d, first slot: %v", minLen, grid[minLen-1].End.Local(), - len(planner), len(feedIn), len(solar), + len(grid), len(feedIn), len(solar), firstSlotDuration, ) @@ -196,9 +191,13 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { Timestamps: asTimestamps(dt, now), } - req.Grid = optimizer.GridConfig{ - // hard grid import limit if no price penalty is set by PrcPExcImp - PMaxImp: 15000, + if site.circuit != nil { + if pMaxImp := site.circuit.GetMaxPower(); pMaxImp > 0 { + req.Grid = optimizer.GridConfig{ + // hard grid import limit if no price penalty is set by PrcPExcImp + PMaxImp: float32(pMaxImp), + } + } } add := func(battery optimizer.BatteryConfig, detail batteryDetail) { @@ -230,7 +229,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { continue } - add(site.batteryRequest(dev, b)) + add(site.batteryRequest(dev, b, grid, minLen, firstSlotDuration)) } // empty request- all loadpoints disabled @@ -428,7 +427,7 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati return bat, detail } -func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measurement) (optimizer.BatteryConfig, batteryDetail) { +func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measurement, grid api.Rates, minLen int, firstSlotDuration time.Duration) (optimizer.BatteryConfig, batteryDetail) { bat := optimizer.BatteryConfig{ CMax: batteryPower, DMax: batteryPower, @@ -463,6 +462,13 @@ func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measureme Capacity: *b.Capacity, } + // tariff forecast-based grid charging demand + if bat.ChargeFromGrid { + if demand := site.applyBatteryGridChargeLimit(bat.CMax, grid, minLen); demand != nil { + bat.PDemand = prorate(demand, firstSlotDuration) + } + } + return bat, detail } @@ -610,52 +616,6 @@ func currentRates(tariff api.Tariff) api.Rates { }) } -func fillMissingRateSlots(rates api.Rates, maxLen int, fallback float64) api.Rates { - if maxLen <= 0 { - return nil - } - - start := time.Now().Truncate(tariff.SlotDuration) - res := make(api.Rates, 0, maxLen) - - slotIndex := 0 - for i := range maxLen { - slotStart := start.Add(time.Duration(i) * tariff.SlotDuration) - slotEnd := slotStart.Add(tariff.SlotDuration) - value := fallback - - for slotIndex < len(rates) && !rates[slotIndex].End.After(slotStart) { - slotIndex++ - } - - if slotIndex < len(rates) && !slotStart.Before(rates[slotIndex].Start) && slotStart.Before(rates[slotIndex].End) { - value = rates[slotIndex].Value - } - - res = append(res, api.Rate{ - Start: slotStart, - End: slotEnd, - Value: value, - }) - } - - return res -} - -func rateHorizonSlots(rates api.Rates) int { - if len(rates) == 0 { - return 0 - } - - start := time.Now().Truncate(tariff.SlotDuration) - end := rates[len(rates)-1].End - if !end.After(start) { - return 0 - } - - return int(end.Sub(start) / tariff.SlotDuration) -} - func timeSteps(minLen int, now time.Time) []int { res := make([]int, 0, minLen) diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 48629d85e46..1ff67a0bc66 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -7,7 +7,6 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/types" - "github.com/evcc-io/evcc/tariff" "github.com/evcc-io/evcc/util/config" optimizer "github.com/evcc-io/optimizer/client" "github.com/stretchr/testify/assert" @@ -114,37 +113,6 @@ func TestBatteryForecastTotals(t *testing.T) { } } -func TestFillMissingRateSlots(t *testing.T) { - now := time.Now().Truncate(tariff.SlotDuration) - - rates := api.Rates{ - {Start: now, End: now.Add(tariff.SlotDuration), Value: 1}, - {Start: now.Add(2 * tariff.SlotDuration), End: now.Add(3 * tariff.SlotDuration), Value: 3}, - } - - got := fillMissingRateSlots(rates, 4, plannerRateFallback) - - require.Len(t, got, 4) - assert.Equal(t, []float64{1, plannerRateFallback, 3, plannerRateFallback}, []float64{ - got[0].Value, - got[1].Value, - got[2].Value, - got[3].Value, - }) -} - -func TestRateHorizonSlotsIgnoresMissingPlannerSlots(t *testing.T) { - now := time.Now().Truncate(tariff.SlotDuration) - - rates := api.Rates{ - {Start: now, End: now.Add(tariff.SlotDuration), Value: 1}, - {Start: now.Add(2 * tariff.SlotDuration), End: now.Add(3 * tariff.SlotDuration), Value: 3}, - {Start: now.Add(95 * tariff.SlotDuration), End: now.Add(96 * tariff.SlotDuration), Value: 96}, - } - - assert.Equal(t, 96, rateHorizonSlots(rates)) -} - func TestBatteryRequestDischargeToGrid(t *testing.T) { ctrl := gomock.NewController(t) @@ -161,7 +129,7 @@ func TestBatteryRequestDischargeToGrid(t *testing.T) { bat, _ := site.batteryRequest(config.NewStaticDevice(config.Named{Name: "battery1"}, meter), types.Measurement{ Soc: &soc, Capacity: &capacity, - }) + }, nil, 0, 0) assert.True(t, bat.DischargeToGrid) } @@ -173,3 +141,4 @@ func TestShouldSkipOptimizerUpdate(t *testing.T) { assert.False(t, shouldSkipOptimizerUpdate(false, now.Add(-3*time.Minute), now)) assert.False(t, shouldSkipOptimizerUpdate(true, now.Add(-time.Minute), now)) } + From be6c679cd6318c1962aacef980844e96bc7af277 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 22:11:30 +0000 Subject: [PATCH 4/5] Revert "Revert \"minor mods to optimizer for my use case\"" Keeps only the applyBatteryGridChargeLimit function restoration, not the other optimizer changes. Co-authored-by: jeffborg <1595430+jeffborg@users.noreply.github.com> --- core/site_optimizer.go | 88 +++++++++++++++++++++++++++---------- core/site_optimizer_test.go | 35 ++++++++++++++- 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 3debe1b4f13..07dcf326aba 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -53,7 +53,8 @@ func (r optimizerResult) MarshalBytes() ([]byte, error) { type batteryType string const ( - OPTIMIZER_URI = "https://optimizer.evcc.io" + OPTIMIZER_URI = "https://optimizer.evcc.io" + plannerRateFallback = 20 batteryTypeLoadpoint batteryType = "loadpoint" batteryTypeVehicle batteryType = "vehicle" @@ -119,11 +120,13 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { solarTariff := site.GetTariff(api.TariffUsageSolar) solar := currentRates(solarTariff) - grid := currentRates(site.GetTariff(api.TariffUsageGrid)) + planner := currentRates(site.GetTariff(api.TariffUsagePlanner)) feedIn := currentRates(site.GetTariff(api.TariffUsageFeedIn)) - minLen := lo.Min([]int{len(grid), len(feedIn)}) - // exclude empty solar forecast from minLen + minLen := len(feedIn) + if plannerLen := rateHorizonSlots(planner); plannerLen > 0 { + minLen = min(minLen, plannerLen) + } if solarTariff != nil && len(solar) > 0 { minLen = min(minLen, len(solar)) } @@ -136,19 +139,21 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { if expectedSlots := 8; minLen < expectedSlots { if solarTariff != nil { - return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(grid), len(feedIn), len(solar)) + return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (planner=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(planner), len(feedIn), len(solar)) } - return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn)) + return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (planner=%d, feedIn=%d)", minLen, expectedSlots, len(planner), len(feedIn)) } + grid := fillMissingRateSlots(planner, minLen, plannerRateFallback) + now := time.Now() dt := timeSteps(minLen, now) firstSlotDuration := time.Duration(dt[0]) * time.Second - site.log.DEBUG.Printf("optimizer: optimizing %d slots until %v: grid=%d, feedIn=%d, solar=%d, first slot: %v", + site.log.DEBUG.Printf("optimizer: optimizing %d slots until %v: planner=%d, feedIn=%d, solar=%d, first slot: %v", minLen, grid[minLen-1].End.Local(), - len(grid), len(feedIn), len(solar), + len(planner), len(feedIn), len(solar), firstSlotDuration, ) @@ -191,13 +196,9 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { Timestamps: asTimestamps(dt, now), } - if site.circuit != nil { - if pMaxImp := site.circuit.GetMaxPower(); pMaxImp > 0 { - req.Grid = optimizer.GridConfig{ - // hard grid import limit if no price penalty is set by PrcPExcImp - PMaxImp: float32(pMaxImp), - } - } + req.Grid = optimizer.GridConfig{ + // hard grid import limit if no price penalty is set by PrcPExcImp + PMaxImp: 15000, } add := func(battery optimizer.BatteryConfig, detail batteryDetail) { @@ -229,7 +230,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { continue } - add(site.batteryRequest(dev, b, grid, minLen, firstSlotDuration)) + add(site.batteryRequest(dev, b)) } // empty request- all loadpoints disabled @@ -427,7 +428,7 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati return bat, detail } -func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measurement, grid api.Rates, minLen int, firstSlotDuration time.Duration) (optimizer.BatteryConfig, batteryDetail) { +func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measurement) (optimizer.BatteryConfig, batteryDetail) { bat := optimizer.BatteryConfig{ CMax: batteryPower, DMax: batteryPower, @@ -462,13 +463,6 @@ func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measureme Capacity: *b.Capacity, } - // tariff forecast-based grid charging demand - if bat.ChargeFromGrid { - if demand := site.applyBatteryGridChargeLimit(bat.CMax, grid, minLen); demand != nil { - bat.PDemand = prorate(demand, firstSlotDuration) - } - } - return bat, detail } @@ -616,6 +610,52 @@ func currentRates(tariff api.Tariff) api.Rates { }) } +func fillMissingRateSlots(rates api.Rates, maxLen int, fallback float64) api.Rates { + if maxLen <= 0 { + return nil + } + + start := time.Now().Truncate(tariff.SlotDuration) + res := make(api.Rates, 0, maxLen) + + slotIndex := 0 + for i := range maxLen { + slotStart := start.Add(time.Duration(i) * tariff.SlotDuration) + slotEnd := slotStart.Add(tariff.SlotDuration) + value := fallback + + for slotIndex < len(rates) && !rates[slotIndex].End.After(slotStart) { + slotIndex++ + } + + if slotIndex < len(rates) && !slotStart.Before(rates[slotIndex].Start) && slotStart.Before(rates[slotIndex].End) { + value = rates[slotIndex].Value + } + + res = append(res, api.Rate{ + Start: slotStart, + End: slotEnd, + Value: value, + }) + } + + return res +} + +func rateHorizonSlots(rates api.Rates) int { + if len(rates) == 0 { + return 0 + } + + start := time.Now().Truncate(tariff.SlotDuration) + end := rates[len(rates)-1].End + if !end.After(start) { + return 0 + } + + return int(end.Sub(start) / tariff.SlotDuration) +} + func timeSteps(minLen int, now time.Time) []int { res := make([]int, 0, minLen) diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 1ff67a0bc66..48629d85e46 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -7,6 +7,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/types" + "github.com/evcc-io/evcc/tariff" "github.com/evcc-io/evcc/util/config" optimizer "github.com/evcc-io/optimizer/client" "github.com/stretchr/testify/assert" @@ -113,6 +114,37 @@ func TestBatteryForecastTotals(t *testing.T) { } } +func TestFillMissingRateSlots(t *testing.T) { + now := time.Now().Truncate(tariff.SlotDuration) + + rates := api.Rates{ + {Start: now, End: now.Add(tariff.SlotDuration), Value: 1}, + {Start: now.Add(2 * tariff.SlotDuration), End: now.Add(3 * tariff.SlotDuration), Value: 3}, + } + + got := fillMissingRateSlots(rates, 4, plannerRateFallback) + + require.Len(t, got, 4) + assert.Equal(t, []float64{1, plannerRateFallback, 3, plannerRateFallback}, []float64{ + got[0].Value, + got[1].Value, + got[2].Value, + got[3].Value, + }) +} + +func TestRateHorizonSlotsIgnoresMissingPlannerSlots(t *testing.T) { + now := time.Now().Truncate(tariff.SlotDuration) + + rates := api.Rates{ + {Start: now, End: now.Add(tariff.SlotDuration), Value: 1}, + {Start: now.Add(2 * tariff.SlotDuration), End: now.Add(3 * tariff.SlotDuration), Value: 3}, + {Start: now.Add(95 * tariff.SlotDuration), End: now.Add(96 * tariff.SlotDuration), Value: 96}, + } + + assert.Equal(t, 96, rateHorizonSlots(rates)) +} + func TestBatteryRequestDischargeToGrid(t *testing.T) { ctrl := gomock.NewController(t) @@ -129,7 +161,7 @@ func TestBatteryRequestDischargeToGrid(t *testing.T) { bat, _ := site.batteryRequest(config.NewStaticDevice(config.Named{Name: "battery1"}, meter), types.Measurement{ Soc: &soc, Capacity: &capacity, - }, nil, 0, 0) + }) assert.True(t, bat.DischargeToGrid) } @@ -141,4 +173,3 @@ func TestShouldSkipOptimizerUpdate(t *testing.T) { assert.False(t, shouldSkipOptimizerUpdate(false, now.Add(-3*time.Minute), now)) assert.False(t, shouldSkipOptimizerUpdate(true, now.Add(-time.Minute), now)) } - From 1f7813059e573184e6fef270c6a4a6162d0ca520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 22:18:22 +0000 Subject: [PATCH 5/5] Restore applyBatteryGridChargeLimit call site in battery loop Agent-Logs-Url: https://github.com/jeffborg/evcc/sessions/52296335-9cce-4494-9502-8ab3fba7ee50 Co-authored-by: jeffborg <1595430+jeffborg@users.noreply.github.com> --- core/site_optimizer.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 07dcf326aba..ed5da498c68 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -230,7 +230,16 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { continue } - add(site.batteryRequest(dev, b)) + bat, detail := site.batteryRequest(dev, b) + + // tariff forecast-based grid charging demand + if bat.ChargeFromGrid { + if demand := site.applyBatteryGridChargeLimit(bat.CMax, grid, minLen); demand != nil { + bat.PDemand = prorate(demand, firstSlotDuration) + } + } + + add(bat, detail) } // empty request- all loadpoints disabled