833 lines
22 KiB
Go
833 lines
22 KiB
Go
package core
|
||
|
||
import (
|
||
"cmp"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"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/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"
|
||
)
|
||
|
||
var (
|
||
eta = float32(0.9) // efficiency of the battery charging/discharging
|
||
batteryPower = float32(6000) // default power of the battery in W
|
||
|
||
mu sync.Mutex
|
||
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"`
|
||
}
|
||
|
||
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"`
|
||
}
|
||
|
||
type batteryResult struct {
|
||
batteryDetail
|
||
Full time.Time `json:"full,omitzero"`
|
||
Empty time.Time `json:"empty,omitzero"`
|
||
}
|
||
|
||
type requestDetails struct {
|
||
Timestamps []time.Time `json:"timestamp"`
|
||
BatteryDetails []batteryDetail `json:"batteryDetails"`
|
||
}
|
||
|
||
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")
|
||
|
||
func (site *Site) optimizerUpdateAsync() {
|
||
if !mu.TryLock() {
|
||
return
|
||
}
|
||
defer mu.Unlock()
|
||
|
||
if time.Since(optimizerUpdated) < 2*time.Minute {
|
||
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
|
||
}
|
||
|
||
optimizerUpdated = time.Now()
|
||
|
||
if err != nil {
|
||
site.log.ERROR.Println("optimizer:", err)
|
||
}
|
||
}()
|
||
|
||
err = site.optimizerUpdate(site.battery.Devices)
|
||
}
|
||
|
||
func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
||
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))
|
||
}
|
||
|
||
uri := cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI)
|
||
if uri == OPTIMIZER_URI {
|
||
// limit to 2 days for sake of performance
|
||
minLen = min(2*96, minLen)
|
||
}
|
||
|
||
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 (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 err
|
||
}
|
||
|
||
// 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 err
|
||
}
|
||
|
||
ft = prorate(scaleAndPrune(solarEnergy, site.solarScale(), minLen), 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 {
|
||
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) {
|
||
battery.PA = pa
|
||
req.Batteries = append(req.Batteries, battery)
|
||
details.BatteryDetails = append(details.BatteryDetails, detail)
|
||
}
|
||
|
||
for _, 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 req, detail := site.loadpointRequest(lp, minLen, firstSlotDuration, grid); req.CMax > 0 {
|
||
add(req, 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
|
||
}
|
||
|
||
add(site.batteryRequest(dev, b, grid, minLen, firstSlotDuration))
|
||
}
|
||
|
||
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(uri, 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)
|
||
}
|
||
|
||
if resp.JSON200.Status != optimizer.Optimal {
|
||
return errors.New(string(resp.JSON200.Status))
|
||
}
|
||
|
||
site.publish("evopt", optimizerResult{
|
||
Updated: time.Now(),
|
||
Req: req,
|
||
Res: *resp.JSON200,
|
||
Details: details,
|
||
})
|
||
|
||
var batteries []batteryResult
|
||
for i, batReq := range req.Batteries {
|
||
batResp := resp.JSON200.Batteries[i]
|
||
|
||
batResult := batteryResult{
|
||
batteryDetail: details.BatteryDetails[i],
|
||
Full: matchSoc(batResp.StateOfCharge, func(soc float32) bool {
|
||
return soc >= batReq.SMax
|
||
}),
|
||
Empty: matchSoc(batResp.StateOfCharge, func(soc float32) bool {
|
||
return soc <= batReq.SMin
|
||
}),
|
||
}
|
||
|
||
batteries = append(batteries, batResult)
|
||
}
|
||
|
||
site.publish("evopt-batteries", batteries)
|
||
|
||
site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries)
|
||
|
||
site.publish(keys.Battery, site.battery)
|
||
|
||
return nil
|
||
}
|
||
|
||
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.
|
||
func batteryForecastSocExtremes(req []optimizer.BatteryConfig, resp []optimizer.BatteryResult) (*batteryForecastSlot, *batteryForecastSlot) {
|
||
homeIndices := lo.FilterMap(req, func(b optimizer.BatteryConfig, i int) (int, bool) {
|
||
return i, b.SCapacity > 0
|
||
})
|
||
if len(homeIndices) == 0 || len(resp) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
totalCapacity := lo.SumBy(homeIndices, func(i int) float32 { return req[i].SCapacity })
|
||
totalSMax := lo.SumBy(homeIndices, func(i int) float32 { return req[i].SMax })
|
||
totalSMin := lo.SumBy(homeIndices, func(i int) float32 { return req[i].SMin })
|
||
|
||
var high, low *batteryForecastSlot
|
||
for i := range resp[homeIndices[0]].StateOfCharge {
|
||
sum := lo.SumBy(homeIndices, func(idx int) float32 { return resp[idx].StateOfCharge[i] })
|
||
soc := float64(sum/totalCapacity) * 100
|
||
fullReached := totalSMax > 0 && sum >= totalSMax
|
||
emptyReached := sum <= totalSMin
|
||
|
||
// first slot at SMax wins for highest
|
||
if high == nil || (!high.limit && (soc > high.soc || fullReached)) {
|
||
high = &batteryForecastSlot{slot: i, soc: soc, limit: fullReached}
|
||
}
|
||
// first slot at SMin wins for lowest
|
||
if low == nil || (!low.limit && (soc < low.soc || emptyReached)) {
|
||
low = &batteryForecastSlot{slot: i, soc: soc, limit: emptyReached}
|
||
}
|
||
}
|
||
|
||
return 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(),
|
||
}
|
||
|
||
// 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 {
|
||
bat.PDemand = prorate(demand, firstSlotDuration)
|
||
}
|
||
|
||
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) {
|
||
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()
|
||
|
||
if api.HasCap[api.BatteryController](instance) {
|
||
bat.ChargeFromGrid = true
|
||
}
|
||
|
||
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()
|
||
bat.SMin = float32(*b.Capacity * minSoc * 10) // Wh
|
||
bat.SMax = float32(*b.Capacity * maxSoc * 10) // Wh
|
||
}
|
||
|
||
detail := batteryDetail{
|
||
Type: batteryTypeBattery,
|
||
Name: dev.Config().Name,
|
||
Title: deviceProperties(dev).Title,
|
||
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
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
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:]
|
||
}
|
||
|
||
// 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)
|
||
}
|