-
- Result: Charging Plan
-
- {{ evopt.res.status }} ・
- {{
- fmtMoney(
- (evopt.res.objective_value || 0) * -1,
- currency,
- true,
- true
- )
- }}
- net cost
-
-
+ Result: Charging Plan
import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue";
+import OptimizeHeader from "../components/Optimize/OptimizeHeader.vue";
import BatteryConfigurationTable from "../components/Optimize/BatteryConfigurationTable.vue";
import SocChart from "../components/Optimize/SocChart.vue";
import ChargeChart from "../components/Optimize/ChargeChart.vue";
@@ -154,6 +138,7 @@ export default defineComponent({
name: "Optimize",
components: {
TopHeader: Header,
+ OptimizeHeader,
BatteryConfigurationTable,
SocChart,
ChargeChart,
@@ -164,7 +149,7 @@ export default defineComponent({
mixins: [formatter],
data() {
return {
- optimizeCooldown: false,
+ pending: false,
};
},
head() {
@@ -177,19 +162,19 @@ export default defineComponent({
currency() {
return store.state.currency || CURRENCY.EUR;
},
- statusBadgeClass() {
- if (!this.evopt?.res.status) return "bg-secondary";
-
- switch (this.evopt.res.status) {
- case "Optimal":
- return "bg-success";
- case "Infeasible":
- return "bg-danger";
- case "Unbounded":
- return "bg-warning";
- default:
- return "bg-secondary";
- }
+ chargingStrategies(): string[] {
+ return store.state.optimizerChargingStrategies || [];
+ },
+ optimizerChargingStrategy(): string {
+ return store.state.optimizerChargingStrategy || "";
+ },
+ netCost(): number {
+ return (this.evopt?.res?.objective_value || 0) * -1;
+ },
+ horizonHours(): number {
+ const dt = this.evopt?.req?.time_series?.dt;
+ if (!dt?.length) return 0;
+ return Math.round(dt.reduce((sum, s) => sum + s, 0) / 3600);
},
deviceColors() {
return deviceColorMap(store.state.deviceColors);
@@ -216,14 +201,18 @@ export default defineComponent({
},
},
watch: {
- evopt() {
- this.optimizeCooldown = false;
+ "evopt.updated"() {
+ // re-enable the refresh action once a fresh optimizer run lands
+ this.pending = false;
},
},
methods: {
optimizeNow() {
+ this.pending = true;
api.post("optimize");
- this.optimizeCooldown = true;
+ },
+ changeChargingStrategy(value: string) {
+ api.post(`optimizerchargingstrategy/${value}`);
},
dimColorBy25Percent(color: string): string {
// Convert color to 25% opacity (40 in hex = 25% of 255)
diff --git a/core/keys/site.go b/core/keys/site.go
index 8ea653b98..658484c3c 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -44,6 +44,10 @@ const (
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
+ // optimizer
+ OptimizerChargingStrategy = "optimizerChargingStrategy"
+ OptimizerChargingStrategies = "optimizerChargingStrategies"
+
// battery status
Battery = "battery"
BatteryMode = "batteryMode"
diff --git a/core/site.go b/core/site.go
index bddf8a88e..7a6f83673 100644
--- a/core/site.go
+++ b/core/site.go
@@ -80,6 +80,9 @@ type Site struct {
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
batteryGridChargeLimit *float64 // grid charging limit
+ // optimizer settings
+ optimizerChargingStrategy string // optimizer grid charging strategy
+
loadpoints []*Loadpoint // Loadpoints
tariffs *tariff.Tariffs // Tariffs
coordinator *coordinator.Coordinator // Vehicles
@@ -347,6 +350,13 @@ func (site *Site) restoreSettings() error {
return err
}
}
+ if v, err := settings.String(keys.OptimizerChargingStrategy); err == nil && v != "" {
+ if err := site.SetOptimizerChargingStrategy(v); err != nil {
+ site.log.WARN.Printf("optimizer charging strategy: %v", err)
+ }
+ }
+ site.publish(keys.OptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
+ site.publish(keys.OptimizerChargingStrategies, optimizerChargingStrategies)
// drop legacy accumulator-based forecast settings (now stored via metrics collector)
settings.Delete("solarAccForecast")
diff --git a/core/site/api.go b/core/site/api.go
index ff5d6310d..6d187577c 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -54,6 +54,11 @@ type API interface {
// SetBatteryGridChargeLimit sets the grid charge limit
SetBatteryGridChargeLimit(limit *float64) error
+ // GetOptimizerChargingStrategy gets the optimizer grid charging strategy
+ GetOptimizerChargingStrategy() string
+ // SetOptimizerChargingStrategy sets the optimizer grid charging strategy
+ SetOptimizerChargingStrategy(strategy string) error
+
//
// power and energy
//
diff --git a/core/site_api.go b/core/site_api.go
index 1cecd57bc..cadfd562a 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -2,6 +2,8 @@ package core
import (
"errors"
+ "fmt"
+ "slices"
"strings"
"time"
@@ -383,6 +385,43 @@ func (site *Site) SetBatteryGridChargeLimit(val *float64) error {
return nil
}
+// GetOptimizerChargingStrategy returns the optimizer grid charging strategy,
+// falling back to the default when unset.
+func (site *Site) GetOptimizerChargingStrategy() string {
+ site.RLock()
+ defer site.RUnlock()
+ if site.optimizerChargingStrategy == "" {
+ return defaultOptimizerChargingStrategy
+ }
+ return site.optimizerChargingStrategy
+}
+
+// SetOptimizerChargingStrategy validates and persists the optimizer grid
+// charging strategy and re-runs the optimizer when it changes.
+func (site *Site) SetOptimizerChargingStrategy(strategy string) error {
+ if !slices.Contains(optimizerChargingStrategies, strategy) {
+ return fmt.Errorf("invalid optimizer charging strategy: %s", strategy)
+ }
+
+ site.Lock()
+ changed := site.optimizerChargingStrategy != strategy
+ if changed {
+ site.optimizerChargingStrategy = strategy
+ }
+ site.Unlock()
+
+ if changed {
+ site.log.DEBUG.Println("set optimizer charging strategy:", strategy)
+ settings.SetString(keys.OptimizerChargingStrategy, strategy)
+ site.publish(keys.OptimizerChargingStrategy, strategy)
+
+ // re-run the optimizer so the new strategy takes effect immediately
+ site.triggerOptimizer()
+ }
+
+ return nil
+}
+
// GetBatteryMode returns the battery mode
func (site *Site) GetBatteryMode() api.BatteryMode {
site.RLock()
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
index ab52df9ee..f00037756 100644
--- a/core/site_optimizer.go
+++ b/core/site_optimizer.go
@@ -36,11 +36,39 @@ var (
optimizerUpdated time.Time
)
+// optimizerChargingStrategies are the valid grid charging strategies; the first
+// entry is the default and preserves the previous hard-coded behavior.
+var optimizerChargingStrategies = []string{
+ string(optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport),
+ string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks),
+ string(optimizer.OptimizerStrategyChargingStrategyNone),
+}
+
+const defaultOptimizerChargingStrategy = string(optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport)
+
+// triggerOptimizer re-runs the optimizer immediately so a changed setting takes
+// effect without waiting for the next slot. It is a no-op when the optimizer is
+// not active or a run is already in progress; the running update reflects the
+// change on its next slot.
+func (site *Site) triggerOptimizer() {
+ if !sponsor.IsAuthorized() || !optimizerEnabled() {
+ return
+ }
+ if !mu.TryLock() {
+ return
+ }
+ optimizerUpdated = time.Time{} // bypass the slot/debounce gate
+ mu.Unlock()
+
+ go site.optimizerUpdateAsync()
+}
+
// optimizerResult wraps the optimizer publish payload to implement BytesMarshaler.
// This ensures publishComplex serializes it as a single JSON message instead of
// recursively decomposing each struct field and array element into individual MQTT
// topics (~1,500 messages per optimizer run).
type optimizerResult struct {
+ Updated time.Time `json:"updated"`
Req optimizer.OptimizationInput `json:"req"`
Res optimizer.OptimizationResult `json:"res"`
Details requestDetails `json:"details"`
@@ -164,7 +192,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
req := optimizer.OptimizationInput{
Strategy: optimizer.OptimizerStrategy{
- ChargingStrategy: optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport, // AttenuateGridPeaks
+ ChargingStrategy: optimizer.OptimizerStrategyChargingStrategy(site.GetOptimizerChargingStrategy()),
DischargingStrategy: optimizer.OptimizerStrategyDischargingStrategyDischargeBeforeImport,
},
EtaC: eta,
@@ -217,6 +245,10 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
}
for i, dev := range site.batteryMeters {
+ // measurements may lag the configured meters on an off-cycle trigger
+ if i >= len(battery) {
+ break
+ }
b := battery[i]
if b.Capacity == nil || *b.Capacity == 0 || b.Soc == nil {
@@ -258,6 +290,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
}
site.publish("evopt", optimizerResult{
+ Updated: time.Now(),
Req: req,
Res: *resp.JSON200,
Details: details,
diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go
index 9c5091985..3a42173a1 100644
--- a/core/site_optimizer_test.go
+++ b/core/site_optimizer_test.go
@@ -6,6 +6,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
+ "github.com/evcc-io/evcc/util"
optimizer "github.com/evcc-io/optimizer/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -148,3 +149,17 @@ func TestBatteryForecastSocExtremes(t *testing.T) {
})
}
}
+func TestOptimizerChargingStrategy(t *testing.T) {
+ site := &Site{log: util.NewLogger("foo")}
+
+ // default when unset
+ assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
+
+ // invalid value rejected, strategy unchanged
+ require.Error(t, site.SetOptimizerChargingStrategy("bogus"))
+ assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
+
+ // valid change is applied (re-trigger is gated on sponsor/enabled, not unit-tested here)
+ require.NoError(t, site.SetOptimizerChargingStrategy(string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks)))
+ assert.Equal(t, "attenuate_grid_peaks", site.GetOptimizerChargingStrategy())
+}
diff --git a/server/http.go b/server/http.go
index c38531cca..2956eeccc 100644
--- a/server/http.go
+++ b/server/http.go
@@ -163,6 +163,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"optimize": {"POST", "/optimize", getHandler(site.Optimize)},
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
"devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)},
+
+ "optimizerchargingstrategy": {"POST", "/optimizerchargingstrategy/{value:[a-z_]+}", stringHandler(site.SetOptimizerChargingStrategy, site.GetOptimizerChargingStrategy)},
}
for _, r := range routes {
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index dfd705e8e..ba64a0e26 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -143,6 +143,11 @@ func boolHandler(set func(bool) error, get func() bool) http.HandlerFunc {
return handler(strconv.ParseBool, set, get)
}
+// stringHandler updates string-param api
+func stringHandler(set func(string) error, get func() string) http.HandlerFunc {
+ return handler(func(s string) (string, error) { return s, nil }, set, get)
+}
+
// durationHandler updates duration-param api
func durationHandler(set func(time.Duration) error, get func() time.Duration) http.HandlerFunc {
return handler(util.ParseDuration, set, get)