evcc-io/core/site_optimizer.go
2025-08-24 16:57:21 +02:00

455 lines
10 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 (
"context"
"errors"
"fmt"
"net/http"
"os"
"time"
evopt "github.com/andig/evopt/client"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/jinzhu/now"
"github.com/samber/lo"
"moul.io/http2curl"
)
var (
eta = float32(0.9) // efficiency of the battery charging/discharging
batteryPower = float32(6000) // power of the battery in W
updated time.Time
)
type batteryType string
const (
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 responseDetails struct {
Timestamps []time.Time `json:"timestamp"`
BatteryDetails []batteryDetail `json:"batteryDetails"`
}
func (site *Site) optimizerUpdateAsync(battery []measurement) {
if time.Since(updated) < 5*time.Minute {
return
}
var err error
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic %v", r)
}
if err != nil {
site.log.ERROR.Println("optimizer:", err)
}
}()
err = site.optimizerUpdate(battery)
updated = time.Now()
}
func (site *Site) optimizerUpdate(battery []measurement) error {
uri := os.Getenv("EVOPT_URI")
if uri == "" {
return nil
}
solar := currentRates(site.GetTariff(api.TariffUsageSolar))
grid := currentRates(site.GetTariff(api.TariffUsageGrid))
feedIn := currentRates(site.GetTariff(api.TariffUsageFeedIn))
minLen := lo.Min([]int{len(grid), len(feedIn), len(solar)})
if minLen < 8 {
return fmt.Errorf("not enough slots for optimization: %d (grid=%d, feedIn=%d, solar=%d)", minLen, len(grid), len(feedIn), len(solar))
}
dt := timeSteps(minLen)
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 := site.homeProfile(minLen)
solarEnergy, err := ratesToEnergy(solar, firstSlotDuration)
if err != nil {
return err
}
req := evopt.OptimizationInput{
Strategy: &evopt.OptimizerStrategy{
ChargingStrategy: lo.ToPtr(evopt.ChargeBeforeExport),
},
EtaC: &eta,
EtaD: &eta,
TimeSeries: evopt.TimeSeries{
Dt: dt,
Gt: asFloat32(gt),
PN: maxValues(grid, 1e3, minLen),
PE: maxValues(feedIn, 1e3, minLen),
Ft: maxValues(solarEnergy, 1, minLen),
},
}
// end of horizon Wh value
pa := lo.Min(req.TimeSeries.PN) * eta * 0.99
details := responseDetails{
Timestamps: asTimestamps(dt),
}
for _, lp := range site.Loadpoints() {
// ignore disconnected loadpoints
if lp.GetStatus() == api.StatusA {
continue
}
bat := evopt.BatteryConfig{
ChargeFromGrid: lo.ToPtr(true),
CMin: float32(lp.EffectiveMinPower()),
CMax: float32(lp.EffectiveMaxPower()),
DMax: 0,
SMin: 0,
PA: pa,
}
if profile := loadpointProfile(lp, firstSlotDuration, minLen); profile != nil {
bat.PDemand = lo.ToPtr(asFloat32(profile))
}
detail := batteryDetail{
Type: batteryTypeLoadpoint,
Title: lp.GetTitle(),
}
if v := lp.GetVehicle(); v != nil {
bat.SMax = float32(v.Capacity() * 1e3) // Wh
bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh
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
}
}
}
req.Batteries = append(req.Batteries, bat)
details.BatteryDetails = append(details.BatteryDetails, detail)
}
for i, b := range battery {
if b.Capacity == nil || b.Soc == nil {
continue
}
dev := site.batteryMeters[i]
bat := evopt.BatteryConfig{
CMin: 0,
CMax: batteryPower,
DMax: batteryPower,
SMin: 0,
SMax: float32(*b.Capacity * 1e3), // Wh
SInitial: float32(*b.Capacity * *b.Soc * 10), // Wh
PA: pa,
}
// TODO atm we cannot cannot control charge from grid speed
if _, ok := (dev.Instance()).(api.BatteryController); ok {
bat.ChargeFromGrid = lo.ToPtr(true)
}
req.Batteries = append(req.Batteries, bat)
details.BatteryDetails = append(details.BatteryDetails, batteryDetail{
Type: batteryTypeBattery,
Name: dev.Config().Name,
Title: deviceProperties(dev).Title,
Capacity: *b.Capacity,
})
}
httpClient := request.NewClient(site.log)
httpClient.Timeout = 30 * time.Second
apiClient, err := evopt.NewClientWithResponses(uri, evopt.WithHTTPClient(httpClient))
if err != nil {
return err
}
var curl *http2curl.CurlCommand
resp, err := apiClient.PostOptimizeChargeScheduleWithResponse(context.TODO(), req, func(_ context.Context, req *http.Request) error {
if sponsor.IsAuthorized() {
req.Header.Set("Authorization", "Bearer "+sponsor.Token)
}
curl, _ = http2curl.GetCurlCommand(req)
return nil
})
if err != nil {
return err
}
if resp.StatusCode() == http.StatusInternalServerError && resp.JSON500.Message != nil {
return errors.New(*resp.JSON500.Message)
}
if resp.StatusCode() == http.StatusBadRequest && resp.JSON400.Message != nil {
return errors.New(*resp.JSON400.Message)
}
if resp.StatusCode() != http.StatusOK {
return fmt.Errorf("invalid status: %d", resp.StatusCode())
}
site.publish("evopt", struct {
Req evopt.OptimizationInput `json:"req"`
Res evopt.OptimizationResult `json:"res"`
Curl string `json:"curl"`
Details responseDetails `json:"details"`
}{
Req: req,
Res: *resp.JSON200,
Curl: curl.String(),
Details: details,
})
return nil
}
// loadpointProfile returns the loadpoint's charging profile in Wh
// TODO consider charging efficiency
func loadpointProfile(lp loadpoint.API, firstSlotDuration time.Duration, 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 i := range minLen {
d := 1.0 // hours
if i == 0 {
d = firstSlotDuration.Hours()
}
deltaEnergy := power * d // 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 {
// kWh over last 30 days
profile, err := metrics.Profile(now.BeginningOfDay().AddDate(0, 0, -30))
if err != nil {
site.log.WARN.Println("optimizer:", err)
return lo.RepeatBy(minLen, func(_ int) float64 {
return 0
})
}
// max 4 days
hours := make([]float64, 0, minLen+1)
combined := combineSlots(profile[:])
for len(hours) <= minLen+24 { // allow for prorating first day
hours = append(hours, combined...)
}
res := prorateFirstHour(time.Now(), hours)
if len(res) < minLen {
panic("minimum home profile length failed")
}
if len(res) > minLen {
res = res[:minLen]
}
// convert to Wh
return lo.Map(res, func(v float64, i int) float64 {
return v * 1e3
})
}
// combineSlots combines 15-minute slots into hourly values
func combineSlots(profile []float64) []float64 {
if profile == nil {
return []float64{}
}
result := make([]float64, 0, 24)
// Process complete hours starting from the start slot
for hour := range 24 {
var sum float64
for i := range 4 {
sum += profile[4*hour+i]
}
result = append(result, sum)
}
return result
}
// prorateFirstHour strips away any slots before "now" and prorates the first remaining hour
// based on remaining time in current hour. The profile contains hourly slots (0-23) that repeat for multiple days.
func prorateFirstHour(now time.Time, profile []float64) []float64 {
// Take only slots from current hour onwards
res := profile[now.Hour():]
// Prorate the first hour based on remaining time in current hour
if minutesIntoHour := now.Minute(); minutesIntoHour > 0 {
fractionOfHour := float64(60-minutesIntoHour) / 60.0
res[0] *= fractionOfHour
}
return res
}
func ratesToEnergy(rr api.Rates, firstSlot time.Duration) (api.Rates, error) {
res := make(api.Rates, 0, len(rr))
for _, r := range rr {
from := r.Start
if len(res) == 0 {
from = endOfHour(r.Start).Add(-firstSlot)
}
if _, err := rr.At(from); err != nil {
return nil, fmt.Errorf("missing solar data for: %v", from)
}
energy := solarEnergy(rr, from, r.End)
if energy < 0 {
return nil, fmt.Errorf("negative solar energy from %v to %v: %.3f", from, r.End, energy)
}
res = append(res, api.Rate{
Start: from,
End: r.End,
Value: energy,
})
}
return res, nil
}
func asFloat32(gt []float64) []float32 {
return lo.Map(gt, func(v float64, i int) float32 {
return float32(v)
})
}
func endOfHour(ts time.Time) time.Time {
return ts.Truncate(time.Hour).Add(time.Hour)
}
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) []int {
res := make([]int, 0, minLen)
eoh := now.BeginningOfHour().Add(time.Hour)
if d := time.Until(eoh); d > time.Second {
res = append(res, int(d.Seconds()))
}
for i := len(res); i < minLen; i++ {
res = append(res, 3600) // 1 hour in seconds
}
return res
}
func asTimestamps(dt []int) []time.Time {
res := make([]time.Time, 0, len(dt))
eoh := endOfHour(time.Now())
res = append(res, eoh.Add(-time.Duration(dt[0])*time.Second))
for i := range len(res) - 1 {
res = append(res, eoh.Add(time.Duration(dt[i+1])*time.Second))
}
return res
}
func maxValues(rates []api.Rate, div float64, maxLen int) []float32 {
res := make([]float32, 0, maxLen)
for _, slot := range rates {
res = append(res, float32(slot.Value/div))
if len(res) >= maxLen {
break
}
}
return res
}