evcc-io/core/site_optimizer.go
2026-08-09 10:37:22 +02:00

1237 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"slices"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/hems/hems"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
optimizer "github.com/evcc-io/optimizer/client"
"github.com/jinzhu/now"
"github.com/samber/lo"
"golang.org/x/exp/constraints"
)
const (
// eta is the efficiency of the battery charging/discharging
eta = 0.9
// batteryPower is the default power of the battery in W
batteryPower = 6000
// optimizerDebounce limits how often on-demand optimizer runs execute
optimizerDebounce = 2 * time.Minute
)
// 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.OptimizerStrategyChargingStrategyAttenuateDemandPeaks),
string(optimizer.OptimizerStrategyChargingStrategyAttenuateFeedinPeaks),
string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks),
string(optimizer.OptimizerStrategyChargingStrategyNone),
}
const defaultOptimizerChargingStrategy = string(optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport)
// optimizerDecaySlots is the number of slots over which measured values decay into the forecast
const optimizerDecaySlots = 4
// 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"`
}
var _ api.BytesMarshaler = (*optimizerResult)(nil)
func (r optimizerResult) MarshalBytes() ([]byte, error) {
return json.Marshal(r)
}
type batteryType string
const (
OPTIMIZER_URI = "https://optimizer.evcc.io"
batteryTypeLoadpoint batteryType = "loadpoint"
batteryTypeVehicle batteryType = "vehicle"
batteryTypeBattery batteryType = "battery"
)
type batteryDetail struct {
Type batteryType `json:"type"`
Title string `json:"title,omitempty"`
Name string `json:"name,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
loadpoint *int // originating loadpoint id for loadpoint/vehicle entries
controllable bool // device can act on suggestions
}
// batteryKey and loadpointKey build the canonical device keys used for
// suggestion routing and notifications
func batteryKey(name string) string { return "battery:" + name }
func loadpointKey(id int) string { return fmt.Sprintf("loadpoint:%d", id) }
// key identifies the device across optimizer runs; an empty key means the
// device can't act on a suggestion.
func (d batteryDetail) key() string {
switch {
case d.Type == batteryTypeBattery:
return batteryKey(d.Name)
case d.loadpoint != nil:
return loadpointKey(*d.loadpoint)
default:
return ""
}
}
// currentAction returns the device's current operating mode for suggestion
// comparison. Must only be called for devices with a non-empty key.
func (d batteryDetail) currentAction(site *Site) string {
if d.Type == batteryTypeBattery {
return site.GetBatteryMode().String()
}
return loadpointCurrentAction(site.loadpoints[*d.loadpoint])
}
type batteryResult struct {
batteryDetail
Full time.Time `json:"full,omitzero"`
Empty time.Time `json:"empty,omitzero"`
}
// suggestionThreshold ignores numerical noise around zero power (W)
const suggestionThreshold = 50
// advisory actions for a loadpoint/vehicle slot; battery actions use api.BatteryMode
const (
actionStop = "stop"
actionCharge = "charge"
)
// actionDischarge is the battery-to-grid discharge advisory. It has no matching
// api.BatteryMode, so it always reads as actionable.
const actionDischarge = "discharge"
// evSuggestion notifies when the optimizer's advisory action for a device changes
const evSuggestion = "suggestion"
// pendingSuggestion pairs a device's current-run suggestion with the
// notification event to emit if it represents an actionable change.
type pendingSuggestion struct {
suggestion types.Suggestion
event messenger.Event
}
// suggestionEvent builds the notification event for a device suggestion
func suggestionEvent(detail batteryDetail, s types.Suggestion) messenger.Event {
ev := messenger.Event{Event: evSuggestion, Attributes: map[string]any{
"suggestionAction": s.Action,
"suggestionTitle": detail.Title,
}}
switch {
case detail.Type == batteryTypeBattery:
ev.Attributes["suggestionName"] = detail.Name
case detail.loadpoint != nil:
id := *detail.loadpoint
ev.Loadpoint = &id
}
return ev
}
// currentSlotSuggestion maps the optimizer's first-slot corner result onto an advisory action.
// Because the optimization is linear, the first slot is at an operating-range extreme, so it
// maps cleanly onto the discrete battery mode / loadpoint intent that control would later apply.
// An idle battery is interpreted from the grid flow: importing means discharge is withheld
// (hold), exporting means charging is withheld (holdcharge).
func currentSlotSuggestion(detail batteryDetail, res optimizer.BatteryResult, gridImporting, gridExporting bool, slotHours float64) types.Suggestion {
if slotHours <= 0 || len(res.ChargingPower) == 0 || len(res.DischargingPower) == 0 {
return types.Suggestion{}
}
charge := float64(res.ChargingPower[0]) / slotHours
discharge := float64(res.DischargingPower[0]) / slotHours
s := types.Suggestion{Charge: charge, Discharge: discharge}
if detail.Type == batteryTypeBattery {
idle := charge <= suggestionThreshold && discharge <= suggestionThreshold
switch {
case charge > suggestionThreshold && gridImporting:
// charging while importing means grid charging
s.Action = api.BatteryCharge.String()
case idle && gridImporting:
// idle while importing: discharge is deliberately withheld
s.Action = api.BatteryHold.String()
case idle && gridExporting:
// idle while exporting: surplus is exported instead of charged
s.Action = api.BatteryHoldCharge.String()
case discharge > suggestionThreshold && gridExporting:
// discharging while exporting means battery-to-grid discharge
s.Action = actionDischarge
default:
s.Action = api.BatteryNormal.String()
}
} else if charge > suggestionThreshold {
s.Action = actionCharge
} else {
s.Action = actionStop
}
return s
}
// loadpointCurrentAction returns the loadpoint's current operating mode for
// suggestion comparison, reusing chargeGoalReached so a loadpoint left
// enabled while idle (e.g. vehicle finished at its limit) is treated as
// stopped instead of triggering a spurious pause suggestion.
func loadpointCurrentAction(lp *Loadpoint) string {
lp.RLock()
enabled := lp.enabled
lp.RUnlock()
if enabled && !lp.chargeGoalReached(enabled) {
return actionCharge
}
return actionStop
}
// setSuggestions replaces the suggestions applied on each publish
func (site *Site) setSuggestions(suggestions map[string]types.Suggestion) {
site.Lock()
defer site.Unlock()
site.suggestions = suggestions
}
// suggestion returns the optimizer suggestion for the given device key.
// The actionable flag is evaluated on read against the device's current
// action since that changes between optimizer runs.
func (site *Site) suggestion(key, currentAction string) *types.Suggestion {
site.RLock()
s, ok := site.suggestions[key]
site.RUnlock()
if !ok {
return nil
}
s.Actionable = s.Action != currentAction
return &s
}
// publishSuggestions publishes the loadpoints' suggestions
func (site *Site) publishSuggestions() {
for id, lp := range site.loadpoints {
var val any
if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil {
val = *s
}
site.publishLoadpoint(id, keys.Suggestion, val)
}
}
// clearSuggestions removes all suggestions and the battery forecast when the
// optimizer result is stale
func (site *Site) clearSuggestions() {
site.setSuggestions(nil)
site.battery.Forecast = nil
site.publishBattery()
site.publishSuggestions()
site.Lock()
site.suggestionActions = nil
site.Unlock()
}
// pendingSuggestions collects the stored suggestions with their actionable flag
// evaluated against the devices' current operating mode
func (site *Site) pendingSuggestions(details []batteryDetail) map[string]pendingSuggestion {
pending := make(map[string]pendingSuggestion, len(details))
for _, detail := range details {
key := detail.key()
if key == "" {
continue
}
s := site.suggestion(key, detail.currentAction(site))
if s == nil {
continue
}
pending[key] = pendingSuggestion{suggestion: *s, event: suggestionEvent(detail, *s)}
}
return pending
}
// diffSuggestions updates the tracked actionable optimizer suggestions and
// returns the events to send for devices whose actionable action changed since
// the last run. Non-actionable or vanished devices are pruned so a later
// actionable change re-notifies.
func (site *Site) diffSuggestions(pending map[string]pendingSuggestion) []messenger.Event {
site.Lock()
defer site.Unlock()
if site.suggestionActions == nil {
site.suggestionActions = make(map[string]string)
}
// prune devices that are gone or no longer actionable
for key := range site.suggestionActions {
if p, ok := pending[key]; !ok || !p.suggestion.Actionable {
delete(site.suggestionActions, key)
}
}
var events []messenger.Event
for key, p := range pending {
if !p.suggestion.Actionable || site.suggestionActions[key] == p.suggestion.Action {
continue
}
site.suggestionActions[key] = p.suggestion.Action
events = append(events, p.event)
}
return events
}
type requestDetails struct {
Timestamps []time.Time `json:"timestamp"`
BatteryDetails []batteryDetail `json:"batteryDetails"`
}
// optimizerBattery pairs a battery request entry with its device detail
type optimizerBattery struct {
cfg optimizer.BatteryConfig
detail batteryDetail
}
func optimizerURI() string {
return cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI)
}
const slotsPerHour = float64(time.Hour / tariff.SlotDuration)
// errOptimizerNotReady means battery measurements aren't available yet (e.g. at
// startup); the slot gate is left open so the next cycle retries.
var errOptimizerNotReady = errors.New("battery measurements not ready")
// optimizerUpdateAsync runs the optimizer unless the last run is younger than
// minAge. Pass 0 to force a run, e.g. when a changed setting should take 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) optimizerUpdateAsync(minAge time.Duration) {
if !sponsor.IsAuthorized() || !optimizerEnabled() {
return
}
if !site.optimizerMu.TryLock() {
return
}
defer site.optimizerMu.Unlock()
if minAge == 0 {
// keep the gate open so a not-ready run is retried on the next cycle
site.optimizerUpdated = time.Time{}
} else if time.Since(site.optimizerUpdated) < minAge {
return
}
var err error
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic %v", r)
}
// not ready yet: keep the gate open for an immediate retry next cycle
if errors.Is(err, errOptimizerNotReady) {
return
}
site.optimizerUpdated = time.Now()
if err != nil {
site.log.ERROR.Println("optimizer:", err)
// stale advice must not linger
site.clearSuggestions()
}
}()
err = site.optimizerUpdate(site.battery.Devices)
}
// optimizerRequest assembles the optimizer request and the matching device
// details from tariffs, home profile, loadpoints and battery meters
func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.OptimizationInput, requestDetails, error) {
var req optimizer.OptimizationInput
var details requestDetails
solarTariff := site.GetTariff(api.TariffUsageSolar)
solar := currentRates(solarTariff)
grid := currentRates(site.GetTariff(api.TariffUsageGrid))
feedIn := currentRates(site.GetTariff(api.TariffUsageFeedIn))
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))
}
if optimizerURI() == OPTIMIZER_URI {
// limit to 2 days for sake of performance
minLen = min(2*96, minLen)
}
if expectedSlots := 8; minLen < expectedSlots {
if solarTariff != nil {
return req, details, 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 req, details, fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn))
}
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",
minLen,
grid[minLen-1].End.Local(),
len(grid), len(feedIn), len(solar),
firstSlotDuration,
)
gt, err := site.homeProfile(minLen)
if err != nil {
return req, details, err
}
// blend measured energy of the last metrics slot into the first slots
if v := site.measuredSlotEnergy(metrics.Home); v > 0 {
orig := slices.Clone(gt[:min(optimizerDecaySlots, len(gt))])
blendMeasured(gt, v, optimizerDecaySlots)
site.log.DEBUG.Printf("optimizer: home slots updated with measured %.0fWh: %.0f -> %.0f", v, orig, gt[:len(orig)])
}
// allow empty solar forecast
ft := lo.RepeatBy(minLen, func(i int) float32 { return float32(0) })
if solarTariff != nil && len(solar) > 0 {
solarEnergy, err := solarRatesToEnergy(solar)
if err != nil {
return req, details, err
}
scale := site.effectiveSolarScale()
ftSlots := scaleAndPrune(solarEnergy, scale, minLen)
// decay the scale derived from measured vs forecasted energy of the last completed slot
if pv, fcst := site.measuredSlotEnergy(site.Meters.PVMetersRef...), site.measuredSlotEnergy(metrics.Forecast)*scale; pv > 0 && fcst > 0 {
orig := slices.Clone(ftSlots[:min(optimizerDecaySlots, len(ftSlots))])
blendScale(ftSlots, pv/fcst, optimizerDecaySlots)
site.log.DEBUG.Printf("optimizer: pv slots updated with scale %.2f: %.0f -> %.0f", pv/fcst, orig, ftSlots[:len(orig)])
}
ft = prorate(ftSlots, firstSlotDuration)
}
req = optimizer.OptimizationInput{
Strategy: optimizer.OptimizerStrategy{
ChargingStrategy: optimizer.OptimizerStrategyChargingStrategy(site.GetOptimizerChargingStrategy()),
DischargingStrategy: optimizer.OptimizerStrategyDischargingStrategyDischargeBeforeImport,
},
EtaC: eta,
EtaD: eta,
TimeSeries: optimizer.TimeSeries{
Dt: dt,
Gt: prorate(gt, firstSlotDuration),
Ft: ft,
PN: scaleAndPrune(grid, 0.001, minLen),
PE: scaleAndPrune(feedIn, 0.001, minLen),
},
}
// end of horizon Wh value
pa := lo.Min(req.TimeSeries.PN) * eta * 0.99
details = requestDetails{
Timestamps: asTimestamps(dt, now),
}
if site.circuit != nil {
if pMaxImp := site.circuit.GetMaxPower(); pMaxImp > 0 {
// hard grid import limit if no price penalty is set by PrcPExcImp
req.Grid.PMaxImp = float32(pMaxImp)
}
}
// static grid export limit configured in the UI: export is capped at this
// power, excess PV is curtailed instead of exported
if limit := site.GetGridExportLimit(); limit > 0 {
req.Grid.PMaxExp = float32(limit)
}
// soft grid feed-in cap from active HEMS curtailment (e.g. German 70% rule)
// wins over the static limit while active
if curtailed := hems.Curtailed(site.hems); curtailed != nil && *curtailed {
if pMaxExp := site.hems.MaxProductionPower(); pMaxExp != nil {
req.Grid.PMaxExp = float32(*pMaxExp)
}
}
var batteries []optimizerBattery
for id, lp := range site.Loadpoints() {
// ignore disconnected loadpoints, including StatusNone
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
continue
}
if v := lp.GetVehicle(); v == nil || v.Capacity() == 0 {
continue
}
// skip disabled loadpoints
if cfg, detail := site.loadpointRequest(lp, minLen, firstSlotDuration, grid); cfg.CMax > 0 {
detail.loadpoint = &id
batteries = append(batteries, optimizerBattery{cfg, detail})
}
}
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 {
continue
}
cfg, detail := site.batteryRequest(dev, b, grid, minLen, firstSlotDuration)
batteries = append(batteries, optimizerBattery{cfg, detail})
}
for _, b := range batteries {
b.cfg.PA = pa
req.Batteries = append(req.Batteries, b.cfg)
details.BatteryDetails = append(details.BatteryDetails, b.detail)
}
return req, details, nil
}
func (site *Site) optimizerUpdate(battery []types.Measurement) error {
req, details, err := site.optimizerRequest(battery)
if err != nil {
return err
}
if len(req.Batteries) == 0 {
// meters configured but measurements not in yet: retry instead of
// consuming the slot gate
if len(site.batteryMeters) > 0 {
return errOptimizerNotReady
}
return nil // nothing to optimize
}
httpClient := request.NewClient(site.log)
httpClient.Timeout = 90 * time.Second
apiClient, err := optimizer.NewClientWithResponses(optimizerURI(), optimizer.WithHTTPClient(httpClient))
if err != nil {
return err
}
resp, err := apiClient.PostOptimizeChargeScheduleWithResponse(context.TODO(), req, func(_ context.Context, req *http.Request) error {
if sponsor.IsAuthorized() {
req.Header.Set("Authorization", "Bearer "+sponsor.Token)
}
return nil
})
if err != nil {
return err
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp)
}
// publish before the status check so the optimizer page stays available
// for diagnosing non-optimal results
site.publish("evopt", optimizerResult{
Updated: time.Now(),
Req: req,
Res: *resp.JSON200,
Details: details,
})
// feasible results are usable, they are just not proven optimal
if status := resp.JSON200.Status; status != optimizer.Optimal && status != optimizer.Feasible {
return errors.New(string(status))
}
site.applyOptimizerResult(req, details.BatteryDetails, *resp.JSON200)
return nil
}
// applyOptimizerResult maps the optimizer response onto suggestions, battery
// forecast and notifications
func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details []batteryDetail, res optimizer.OptimizationResult) {
slotHours := (time.Duration(req.TimeSeries.Dt[0]) * time.Second).Hours()
gridImporting := len(res.GridImport) > 0 && res.GridImport[0] > 0
gridExporting := len(res.GridExport) > 0 && res.GridExport[0] > 0
var batteries []batteryResult
suggestions := make(map[string]types.Suggestion, len(req.Batteries))
for i, batReq := range req.Batteries {
batRes := res.Batteries[i]
detail := details[i]
batteries = append(batteries, batteryResult{
batteryDetail: detail,
Full: matchSoc(batRes.StateOfCharge, func(soc float32) bool {
return soc >= batReq.SMax
}),
Empty: matchSoc(batRes.StateOfCharge, func(soc float32) bool {
return soc <= batReq.SMin
}),
})
suggestion := currentSlotSuggestion(detail, batRes, gridImporting, gridExporting, slotHours)
if suggestion.Action == "" {
continue
}
// uncontrollable devices can't act on a suggestion
if key := detail.key(); key != "" && detail.controllable {
suggestions[key] = suggestion
}
}
site.publish("evopt-batteries", batteries)
site.setSuggestions(suggestions)
site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, res.Batteries)
site.publishBattery()
// publish for all loadpoints so suggestions of dropped-out loadpoints clear
site.publishSuggestions()
// notify on actionable suggestion changes (advisory only, see #31903)
for _, ev := range site.diffSuggestions(site.pendingSuggestions(details)) {
site.pushEvent(ev)
}
}
func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp []optimizer.BatteryResult) *types.BatteryForecast {
if len(resp) == 0 || len(resp[0].StateOfCharge) == 0 {
return nil
}
high, low := batteryForecastSocExtremes(req, resp)
if high == nil && low == nil {
return nil
}
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(cutoff) {
return nil
}
return &types.BatteryForecastPoint{Soc: p.soc, Time: ts, Limit: p.limit}
}
res := types.BatteryForecast{
Highest: point(high),
Lowest: point(low),
}
if res.Highest == nil && res.Lowest == nil {
return nil
}
return &res
}
type batteryForecastSlot struct {
slot int
soc float64 // percent
limit bool // true when SMax (highest) or SMin (lowest) boundary reached
}
// batteryForecastSocExtremes returns the highest and lowest aggregate SOC
// points across home batteries (SCapacity > 0) over the forecast horizon.
// The Limit flag indicates whether the SOC reached the configured SMax (for
// the highest point) or SMin (for the lowest point) boundary - in which case
// the battery is forecasted to become fully charged or empty.
// Returns nil for either point when no home battery is present or when the
// battery already is at the respective limit.
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}
}
}
// battery is already at the limit - announcing it will become full/empty is pointless
if high != nil && high.limit && high.slot == 0 {
high = nil
}
if low != nil && low.limit && low.slot == 0 {
low = nil
}
return high, low
}
func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDuration time.Duration, grid api.Rates) (optimizer.BatteryConfig, batteryDetail) {
bat := optimizer.BatteryConfig{
ChargeFromGrid: true,
CMin: float32(lp.EffectiveMinPower()),
CMax: float32(lp.EffectiveMaxPower()),
DMax: 0,
SMin: 0,
// PA: pa,
}
if profile := loadpointProfile(lp, minLen); profile != nil {
bat.PDemand = prorate(profile, firstSlotDuration)
}
detail := batteryDetail{
Type: batteryTypeLoadpoint,
Title: lp.GetTitle(),
controllable: true,
}
// vehicle
v := lp.GetVehicle()
maxSoc := v.Capacity() * 1e3 // Wh
if v := lp.EffectiveLimitSoc(); v > 0 {
maxSoc *= float64(v) / 100
} else if v := lp.GetLimitEnergy(); v > 0 {
maxSoc = v * 1e3
}
bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh
bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum
detail.Type = batteryTypeVehicle
detail.Capacity = v.Capacity()
if vt := v.GetTitle(); vt != "" {
if detail.Title != "" {
detail.Title += " – "
}
detail.Title += vt
}
// find vehicle name/id
for _, dev := range config.Vehicles().Devices() {
if dev.Instance() == v {
detail.Name = dev.Config().Name
}
}
var demand []float32
switch lp.GetMode() {
case api.ModeOff:
// disable charging
bat.CMax = 0
case api.ModeNow:
// forced max charging
demand = continuousDemand(lp, minLen)
case api.ModeMinPV:
// forced min charging
demand = continuousDemand(lp, minLen)
// add smartcost limit and plan goal, if configured
demand = applySmartCostLimit(lp, demand, grid, minLen)
site.applyPlanGoal(lp, &bat, minLen)
case api.ModePV:
// add smartcost limit and plan goal, if configured
demand = applySmartCostLimit(lp, nil, grid, minLen)
site.applyPlanGoal(lp, &bat, minLen)
}
if demand != nil {
// after prorate, so the shortened first slot counts with the energy it really carries
bat.PDemand = clearDemandWhenFull(prorate(demand, firstSlotDuration), bat.SMax-bat.SInitial)
}
return bat, detail
}
// clearDemandWhenFull zeroes the charge demand from the slot the accumulated energy fills the
// vehicle. The optimizer drops the demand at s_max anyway, but pays two binaries per slot to
// detect it, so slots that cannot bind are worth not asking about. Losses are accounted for.
//
// The cut assumes the demand is met every slot. A grid import limit can throttle charging below
// it, moving the real fill point later than the estimate - the next request corrects that from
// the measured soc, and the near slots are never affected because the cut sits a full charge away.
func clearDemandWhenFull(demand []float32, headroom float32) []float32 {
res := slices.Clone(demand)
var acc float32
for i, d := range res {
if acc >= headroom {
res[i] = 0
continue
}
acc += d * eta
}
return res
}
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,
SCapacity: float32(*b.Capacity * 1e3), // Wh
SInitial: float32(*b.Capacity * *b.Soc * 10), // Wh
// PA: pa,
}
instance := dev.Instance()
controllable := api.HasCap[api.BatteryController](instance)
if controllable {
bat.ChargeFromGrid = true
bat.DischargeToGrid = site.GetBatteryGridDischarge()
}
if m, ok := api.Cap[api.BatteryPowerLimiter](instance); ok {
charge, discharge := m.GetPowerLimits()
bat.CMax = float32(charge)
bat.DMax = float32(discharge)
}
if m, ok := api.Cap[api.BatterySocLimiter](instance); ok {
minSoc, maxSoc := m.GetSocLimits()
if maxSoc == 0 {
maxSoc = 100 // empty/unset maxsoc means no upper limit
}
// clamp against current soc to prevent infeasible if it is outside the configured limits
bat.SMin = min(bat.SInitial, float32(*b.Capacity*minSoc*10)) // Wh
bat.SMax = max(bat.SInitial, float32(*b.Capacity*maxSoc*10)) // Wh
}
detail := batteryDetail{
Type: batteryTypeBattery,
Name: dev.Config().Name,
Title: deviceProperties(dev).Title,
Capacity: *b.Capacity,
controllable: controllable,
}
// 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
}
func matchSoc(ts []float32, fun func(float32) bool) time.Time {
for i, soc := range ts {
if fun(soc) {
// TODO first slot
return time.Now().Add(time.Duration(i+1) * tariff.SlotDuration).Round(time.Second)
}
}
return time.Time{}
}
// continuousDemand creates a slice of power demands depending on loadpoint mode
func continuousDemand(lp loadpoint.API, minLen int) []float32 {
if lp.GetStatus() != api.StatusC {
return nil
}
pwr := lp.EffectiveMaxPower()
if lp.GetMode() == api.ModeMinPV {
pwr = lp.EffectiveMinPower()
}
return lo.RepeatBy(minLen, func(i int) float32 {
return float32(pwr / slotsPerHour)
})
}
// loadpointProfile returns the loadpoint's charging profile in Wh
// TODO consider charging efficiency
func loadpointProfile(lp loadpoint.API, minLen int) []float64 {
mode := lp.GetMode()
status := lp.GetStatus()
if status != api.StatusC || (mode != api.ModeMinPV && mode != api.ModeNow) {
return nil
}
power := lp.GetChargePower()
if minP := lp.EffectiveMinPower(); mode == api.ModeMinPV && minP < power {
power = minP
}
energy := lp.GetRemainingEnergy() * 1e3 // Wh
energyKnown := energy > 0
res := make([]float64, 0, minLen)
for range minLen {
deltaEnergy := power * float64(tariff.SlotDuration) / float64(time.Hour) // Wh
if energyKnown && deltaEnergy >= energy {
deltaEnergy = energy
}
energy -= deltaEnergy
res = append(res, deltaEnergy)
}
return res
}
// homeProfile returns the home base load in Wh
func (site *Site) homeProfile(minLen int) ([]float64, error) {
// kWh over last 30 days
profile, err := site.collectors[metrics.Home].EnergyProfile(now.BeginningOfDay().AddDate(0, 0, -30))
if err != nil {
return nil, err
}
// max 4 days
slots := make([]float64, 0, minLen+1)
for len(slots) <= minLen+24*4 { // allow for prorating first day
slots = append(slots, profile[:]...)
}
res := profileSlotsFromNow(slots)
if len(res) < minLen {
return nil, fmt.Errorf("minimum home profile length %d is less than required %d", len(res), minLen)
}
if len(res) > minLen {
res = res[:minLen]
}
// convert to Wh
return lo.Map(res, func(v float64, i int) float64 {
return v * 1e3
}), nil
}
// profileSlotsFromNow strips away any slots before "now".
// The profile contains 48 15min slots (00:00-23:45) that repeat for multiple days.
func profileSlotsFromNow(profile []float64) []float64 {
firstSlot := int(time.Now().Truncate(tariff.SlotDuration).Sub(now.BeginningOfDay()) / tariff.SlotDuration)
return profile[firstSlot:]
}
// measuredSlotEnergy returns the summed energy in Wh of the last completed
// metrics slot for the given collector refs, 0 when not available
func (site *Site) measuredSlotEnergy(refs ...string) float64 {
var sum float64
for _, ref := range refs {
c, ok := site.collectors[ref]
if !ok {
return 0
}
v, ok := c.LastSlotEnergy()
if !ok {
return 0
}
sum += v
}
return sum * 1e3
}
// blendMeasured decays the first slots from the measured value into the
// forecast. Slot 0 uses the measured value, the forecast takes over from
// slot decaySlots on.
func blendMeasured[T constraints.Float](slots []T, measured T, decaySlots int) {
for i := range min(decaySlots, len(slots)) {
w := T(decaySlots-i) / T(decaySlots)
slots[i] = w*measured + (1-w)*slots[i]
}
}
// blendScale decays a scale factor towards 1 over the first slots.
// Slot 0 is scaled by the full factor, from slot decaySlots on it is 1.
func blendScale[T constraints.Float](slots []T, scale float64, decaySlots int) {
for i := range min(decaySlots, len(slots)) {
w := float64(decaySlots-i) / float64(decaySlots)
slots[i] = T(float64(slots[i]) * (w*scale + (1 - w)))
}
}
// prorate adjusts the first slot's energy amount according to remaining duration
func prorate[T constraints.Float](slots []T, firstSlotDuration time.Duration) []float32 {
// return empty slice instead of nil to make api happy
if len(slots) == 0 {
return []float32{}
}
res := slices.Clone(slots)
res[0] = res[0] * T(firstSlotDuration) / T(tariff.SlotDuration)
return lo.Map(res, func(f T, _ int) float32 {
return float32(f)
})
}
func solarRatesToEnergy(rr api.Rates) (api.Rates, error) {
res := make(api.Rates, 0, len(rr))
for _, r := range rr {
energy := solarEnergy(rr, r.Start, r.End)
if energy < 0 {
return nil, fmt.Errorf("negative solar energy from %v to %v: %.3f", r.Start, r.End, energy)
}
res = append(res, api.Rate{
Start: r.Start,
End: r.End,
Value: energy,
})
}
return res, nil
}
func currentRates(tariff api.Tariff) api.Rates {
if tariff == nil {
return nil
}
rates, err := tariff.Rates()
if err != nil {
return nil
}
// filter past slots
now := time.Now()
return lo.Filter(rates, func(slot api.Rate, _ int) bool {
return slot.End.After(now)
})
}
func timeSteps(minLen int, now time.Time) []int {
res := make([]int, 0, minLen)
eos := now.Truncate(tariff.SlotDuration).Add(tariff.SlotDuration)
if d := eos.Sub(now); d > time.Second && d < tariff.SlotDuration {
res = append(res, int(d.Seconds()))
}
for i := len(res); i < minLen; i++ {
res = append(res, int(tariff.SlotDuration.Seconds())) // 15min slots
}
return res
}
func asTimestamps(dt []int, now time.Time) []time.Time {
res := make([]time.Time, 0, len(dt))
eos := now.Truncate(tariff.SlotDuration).Add(tariff.SlotDuration)
res = append(res, eos.Add(-time.Duration(dt[0])*time.Second))
for i := range len(dt) - 1 {
res = append(res, res[i].Add(time.Duration(dt[i])*time.Second))
}
return res
}
func scaleAndPrune(rates api.Rates, scale float64, maxLen int) []float32 {
res := make([]float32, 0, maxLen)
for _, slot := range rates {
res = append(res, float32(slot.Value*scale))
if len(res) >= maxLen {
break
}
}
return res
}
func (site *Site) applyPlanGoal(lp loadpoint.API, bat *optimizer.BatteryConfig, minLen int) {
goal, socBased := lp.GetPlanGoal()
if goal <= 0 {
return
}
// Convert to Wh
if vehicle := lp.GetVehicle(); socBased && vehicle != nil {
goal *= vehicle.Capacity() * 10
} else {
goal *= 1000 // Wh
}
ts := lp.EffectivePlanTime()
if ts.IsZero() {
return
}
// TODO precise slot placement
slot := int(time.Until(ts) / tariff.SlotDuration)
if slot >= 0 && slot < minLen {
bat.SGoal = make([]float32, minLen)
bat.SGoal[slot] = float32(goal)
bat.SMax = max(bat.SMax, float32(goal))
} else {
site.log.DEBUG.Printf("plan beyond forecast range or overrun: %.1f at %v slot %d", goal, ts.Round(time.Minute), slot)
}
}
// TODO remove once smart cost limit usage becomes obsolete
func applySmartCostLimit(lp loadpoint.API, demand []float32, grid api.Rates, minLen int) []float32 {
costLimit := lp.GetSmartCostLimit()
if costLimit == nil {
return demand
}
maxLen := min(minLen, len(grid))
// Check if any slots meet the cost limit
if hasAffordableSlots := slices.ContainsFunc(grid[:maxLen], func(r api.Rate) bool {
return r.Value <= *costLimit
}); !hasAffordableSlots {
return demand
}
maxPower := lp.EffectiveMaxPower()
if demand == nil {
demand = make([]float32, minLen)
}
for i := range maxLen {
if grid[i].Value <= *costLimit {
demand[i] = float32(maxPower / slotsPerHour)
}
// else: keep existing demand (either 0 or minPower from ModeMinPV)
}
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
switch resp.StatusCode() {
case http.StatusBadRequest:
errObj = resp.JSON400
case http.StatusInternalServerError:
errObj = resp.JSON500
}
if errObj == nil {
return fmt.Errorf("invalid status: %d: %s", resp.StatusCode(), resp.Body)
}
if len(errObj.Details) > 0 {
var details []string
for field, msg := range errObj.Details {
details = append(details, fmt.Sprintf("%s: %s", field, msg))
}
slices.Sort(details)
return fmt.Errorf("%s (%s)", errObj.Message, strings.Join(details, ", "))
}
return errors.New(errObj.Message)
}