evcc-io/core/site_optimizer.go

1361 lines
40 KiB
Go

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
)
// 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
}
// setBatteryForecast replaces the battery forecast of the cached state
func (site *Site) setBatteryForecast(forecast *types.BatteryForecast) {
site.Lock()
defer site.Unlock()
site.battery.Forecast = forecast
}
// 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 {
if lp == nil {
continue
}
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.setBatteryForecast(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.state().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 {
minLen = slotsUntil(grid, optimizerHorizon(time.Now()), 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
// uncontrollable power of loadpoints that cannot be modelled as storage
var unmodelled float64
for id, lp := range site.ActiveLoadpoints() {
// ignore disconnected loadpoints, including StatusNone
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
continue
}
// no vehicle capacity and no session energy limit to model against:
// account for the consumption as uncontrollable load
if v := lp.GetVehicle(); v == nil || (v.Capacity() == 0 && lp.GetLimitEnergy() == 0) {
unmodelled += unmodelledPower(lp)
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})
}
}
// home profile subtracts all loadpoint power, so unmodelled loadpoints would
// leave the optimizer planning against surplus that is already consumed. Their
// forecast is zero, so the measured power only decays into the near slots -
// without a capacity there is no fill point to assert it any further.
if unmodelled > 0 {
load := make([]float64, minLen)
blendMeasured(load, unmodelled/slotsPerHour, optimizerDecaySlots)
site.log.DEBUG.Printf("optimizer: home slots updated with unmodelled %.0fW loadpoint load: %.0f", unmodelled, load[:min(optimizerDecaySlots, len(load))])
for i, v := range prorate(load, firstSlotDuration) {
req.TimeSeries.Gt[i] += v
}
}
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.setBatteryForecast(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()
capacity := v.Capacity() // kWh
soc := lp.GetSoc() // percent
// without capacity or soc there is no battery state to model, but a session energy
// limit still bounds the charge- use charged energy as state (see remainingLimitEnergy)
if limit := lp.GetLimitEnergy(); limit > 0 && (capacity == 0 || soc == 0) {
bat.SInitial = float32(lp.GetChargedEnergy()) // Wh
bat.SMax = max(bat.SInitial, float32(limit*1e3)) // prevent infeasible if limit already exceeded
} else {
maxSoc := capacity * float64(lp.EffectiveLimitSoc()) * 10 // Wh
bat.SInitial = float32(capacity * soc * 10) // Wh
bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum
}
detail.Type = batteryTypeVehicle
detail.Capacity = capacity
if vt := v.GetTitle(); vt != "" {
if detail.Title != "" {
detail.Title += " (" + vt + ")"
} else {
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, precondition and plan goal, if configured
demand = applySmartCostLimit(lp, demand, grid, minLen)
demand = applyPrecondition(lp, demand, minLen)
site.applyPlanGoal(lp, &bat, minLen)
case api.ModePV:
// add smartcost limit, precondition and plan goal, if configured
demand = applySmartCostLimit(lp, nil, grid, minLen)
demand = applyPrecondition(lp, demand, 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
}
// unmodelledPower returns the uncontrollable power of a connected loadpoint that
// cannot be modelled as storage because the vehicle capacity is unknown
func unmodelledPower(lp loadpoint.API) float64 {
power := lp.GetChargePower()
// minpv keeps drawing at least min power while the vehicle is connected,
// even before the charge meter has caught up
if lp.GetMode() == api.ModeMinPV && lp.GetStatus() == api.StatusC {
power = max(power, lp.EffectiveMinPower())
}
return max(0, power)
}
// 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)
})
}
// optimizerHorizon is the timeframe the hosted optimizer is limited to for sake
// of performance: 48 hours, extended to the end of that day. In the early hours
// the extension would add almost a full day, hence it only applies past 6:00.
func optimizerHorizon(t time.Time) time.Time {
horizon := t.Add(48 * time.Hour)
if t.Hour() < 6 {
return horizon
}
return now.With(horizon).EndOfDay()
}
// slotsUntil limits maxLen to the slots starting before the given horizon
func slotsUntil(rates api.Rates, horizon time.Time, maxLen int) int {
if i := slices.IndexFunc(rates[:min(maxLen, len(rates))], func(slot api.Rate) bool {
return slot.Start.After(horizon)
}); i >= 0 {
return i
}
return maxLen
}
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
}
// applyPrecondition forces max charging power during the planner's precondition window
// ("late charging"), i.e. the last precondition duration before the plan time
func applyPrecondition(lp loadpoint.API, demand []float32, minLen int) []float32 {
precondition := lp.EffectivePlanStrategy().Precondition
if precondition <= 0 {
return demand
}
ts := lp.EffectivePlanTime()
if ts.IsZero() {
return demand
}
// limit to the required charging duration, i.e. "all" must not demand beyond the plan goal
goal, _ := lp.GetPlanGoal()
if required := lp.GetPlanRequiredDuration(goal, lp.EffectiveMaxPower()); required < precondition {
precondition = required
}
if precondition <= 0 {
return demand
}
// TODO precise slot placement
end := time.Until(ts)
start := end - precondition
if end <= 0 {
return demand
}
first := max(int(start/tariff.SlotDuration), 0)
if first >= minLen {
return demand
}
if demand == nil {
demand = make([]float32, minLen)
}
energy := float32(lp.EffectiveMaxPower() / slotsPerHour)
for i := first; i < minLen; i++ {
slotStart := time.Duration(i) * tariff.SlotDuration
overlap := min(end, slotStart+tariff.SlotDuration) - max(start, slotStart)
if overlap <= 0 {
break
}
demand[i] = max(demand[i], energy*float32(overlap)/float32(tariff.SlotDuration))
}
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)
}