340 lines
11 KiB
Go
340 lines
11 KiB
Go
package core
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/core/keys"
|
|
"github.com/evcc-io/evcc/core/metrics"
|
|
"github.com/evcc-io/evcc/tariff"
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/jinzhu/now"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
// forecastSeries and solarDetails implement BytesMarshaler so MQTT publishes one
|
|
// json message per forecast key instead of decomposing every slot into its own
|
|
// topic (several thousand messages per update).
|
|
type forecastSeries [][]float64
|
|
|
|
var _ api.BytesMarshaler = (*forecastSeries)(nil)
|
|
|
|
func (s forecastSeries) MarshalBytes() ([]byte, error) {
|
|
return json.Marshal(s)
|
|
}
|
|
|
|
type solarDetails struct {
|
|
Scale float64 `json:"scale"` // trailing percentile solar scale factor, 1 if unscaled
|
|
Today dailyDetails `json:"today"` // tomorrow
|
|
Tomorrow dailyDetails `json:"tomorrow"` // tomorrow
|
|
DayAfterTomorrow dailyDetails `json:"dayAfterTomorrow"` // day after tomorrow
|
|
Timeseries timeseries `json:"timeseries,omitempty"` // timeseries of forecasted energy
|
|
}
|
|
|
|
var _ api.BytesMarshaler = (*solarDetails)(nil)
|
|
|
|
func (d solarDetails) MarshalBytes() ([]byte, error) {
|
|
return json.Marshal(d)
|
|
}
|
|
|
|
type dailyDetails struct {
|
|
Yield float64 `json:"energy"`
|
|
Complete bool `json:"complete"`
|
|
}
|
|
|
|
// forecastRates publishes rates as [start, end, value] with the timestamps in
|
|
// unix seconds. The forecast is the largest payload evcc sends and RFC3339
|
|
// timestamps are two thirds of it.
|
|
func forecastRates(rr api.Rates) forecastSeries {
|
|
// keep nil for empty rates: shards are published without omitempty
|
|
if len(rr) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return lo.Map(rr, func(r api.Rate, _ int) []float64 {
|
|
return []float64{float64(r.Start.Unix()), float64(r.End.Unix()), r.Value}
|
|
})
|
|
}
|
|
|
|
// greenShare returns
|
|
// - the current green share, calculated for the part of the consumption between powerFrom and powerTo
|
|
// the consumption below powerFrom will get the available green power first
|
|
func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 {
|
|
state := site.state()
|
|
|
|
greenPower := math.Max(0, state.pvPower) + math.Max(0, state.battery.Power)
|
|
greenPowerAvailable := math.Max(0, greenPower-powerFrom)
|
|
|
|
power := powerTo - powerFrom
|
|
share := math.Min(greenPowerAvailable, power) / power
|
|
|
|
if math.IsNaN(share) {
|
|
if greenPowerAvailable > 0 {
|
|
share = 1
|
|
} else {
|
|
share = 0
|
|
}
|
|
}
|
|
|
|
return share
|
|
}
|
|
|
|
// effectivePrice calculates the real energy price based on self-produced and grid-imported energy.
|
|
func (site *Site) effectivePrice(greenShare float64) *float64 {
|
|
if grid, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil {
|
|
feedin, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn))
|
|
if err != nil {
|
|
feedin = 0
|
|
}
|
|
effPrice := grid*(1-greenShare) + feedin*greenShare
|
|
return &effPrice
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy.
|
|
func (site *Site) effectiveCo2(greenShare float64) *float64 {
|
|
if co2, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil {
|
|
effCo2 := co2 * (1 - greenShare)
|
|
return &effCo2
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints float64) {
|
|
site.publish(keys.GreenShareHome, greenShareHome)
|
|
site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints)
|
|
|
|
if v, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil {
|
|
site.publish(keys.TariffGrid, v)
|
|
}
|
|
if v, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn)); err == nil {
|
|
site.publish(keys.TariffFeedIn, v)
|
|
}
|
|
if v, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil {
|
|
site.publish(keys.TariffCo2, v)
|
|
}
|
|
if v, err := tariff.Now(site.GetTariff(api.TariffUsageSolar)); err == nil {
|
|
site.publish(keys.TariffSolar, v)
|
|
}
|
|
if v, err := tariff.Now(site.GetTariff(api.TariffUsageTemperature)); err == nil {
|
|
site.publish(keys.TariffTemperature, v)
|
|
}
|
|
if v := site.effectivePrice(greenShareHome); v != nil {
|
|
site.publish(keys.TariffPriceHome, v)
|
|
}
|
|
if v := site.effectiveCo2(greenShareHome); v != nil {
|
|
site.publish(keys.TariffCo2Home, v)
|
|
}
|
|
if v := site.effectivePrice(greenShareLoadpoints); v != nil {
|
|
site.publish(keys.TariffPriceLoadpoints, v)
|
|
}
|
|
if v := site.effectiveCo2(greenShareLoadpoints); v != nil {
|
|
site.publish(keys.TariffCo2Loadpoints, v)
|
|
}
|
|
|
|
fc := struct {
|
|
Co2 forecastSeries `json:"co2,omitempty"`
|
|
FeedIn forecastSeries `json:"feedin,omitempty"`
|
|
Grid forecastSeries `json:"grid,omitempty"`
|
|
Planner forecastSeries `json:"planner,omitempty"`
|
|
Solar *solarDetails `json:"solar,omitempty"`
|
|
Temperature forecastSeries `json:"temperature,omitempty"`
|
|
}{
|
|
Co2: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageCo2))),
|
|
FeedIn: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageFeedIn))),
|
|
Planner: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsagePlanner))),
|
|
Grid: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageGrid))),
|
|
Temperature: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageTemperature))),
|
|
}
|
|
|
|
// calculate adjusted solar rates
|
|
if solar := tariff.Rates(site.GetTariff(api.TariffUsageSolar)); len(solar) > 0 {
|
|
fc.Solar = new(site.solarDetails(solar))
|
|
}
|
|
|
|
site.publish(keys.Forecast, util.NewSharder(keys.Forecast, fc))
|
|
|
|
site.persistTariffs()
|
|
}
|
|
|
|
// persistTariffs stores tariff values once per 15min boundary. Like the meter
|
|
// collectors it is driven by the update loop, skipping the partial boot slot.
|
|
func (site *Site) persistTariffs() {
|
|
slot := time.Now().Truncate(tariff.SlotDuration)
|
|
|
|
last := site.tariffSlot
|
|
site.tariffSlot = slot
|
|
|
|
// skip repeat ticks within the slot and the partial boot slot
|
|
if last.IsZero() || !slot.After(last) {
|
|
return
|
|
}
|
|
|
|
value := func(u api.TariffUsage) *float64 {
|
|
if r, err := tariff.At(site.GetTariff(u), slot); err == nil {
|
|
return &r.Value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := metrics.PersistTariffs(slot,
|
|
value(api.TariffUsageGrid),
|
|
value(api.TariffUsageFeedIn),
|
|
value(api.TariffUsageCo2),
|
|
value(api.TariffUsageTemperature),
|
|
); err != nil {
|
|
site.log.ERROR.Printf("persist tariffs: %v", err)
|
|
}
|
|
}
|
|
|
|
// forecastSlotEnergy is the energy expected in the slot covering now, integrated
|
|
// the same way as the published forecast so the persisted history matches the
|
|
// curve the UI draws. Beyond the forecast horizon it is zero.
|
|
func forecastSlotEnergy(solar api.Rates, now time.Time) float64 {
|
|
slot := now.Truncate(tariff.SlotDuration)
|
|
return solarEnergy(solar, slot, slot.Add(tariff.SlotDuration)) / 1e3
|
|
}
|
|
|
|
func (site *Site) solarDetails(solar api.Rates) solarDetails {
|
|
res := solarDetails{
|
|
Timeseries: solarTimeseries(solar),
|
|
}
|
|
|
|
last := solar[len(solar)-1].Start
|
|
|
|
bod := now.BeginningOfDay()
|
|
eod := bod.AddDate(0, 0, 1)
|
|
eot := eod.AddDate(0, 0, 1)
|
|
|
|
remainingToday := solarEnergy(solar, time.Now(), eod)
|
|
tomorrow := solarEnergy(solar, eod, eot)
|
|
dayAfterTomorrow := solarEnergy(solar, eot, eot.AddDate(0, 0, 1))
|
|
|
|
res.Today = dailyDetails{
|
|
Yield: remainingToday,
|
|
Complete: !last.Before(eod),
|
|
}
|
|
res.Tomorrow = dailyDetails{
|
|
Yield: tomorrow,
|
|
Complete: !last.Before(eot),
|
|
}
|
|
res.DayAfterTomorrow = dailyDetails{
|
|
Yield: dayAfterTomorrow,
|
|
Complete: !last.Before(eot.AddDate(0, 0, 1)),
|
|
}
|
|
|
|
if err := site.collectors[metrics.Forecast].SetEnergy(forecastSlotEnergy(solar, time.Now())); err != nil {
|
|
site.log.ERROR.Printf("solar forecast collector: %v", err)
|
|
}
|
|
|
|
if r, err := tariff.At(site.GetTariff(api.TariffUsageTemperature), time.Now()); err == nil {
|
|
if err := site.collectors[metrics.Temperature].SetSocTemp(r.Value, true); err != nil {
|
|
site.log.ERROR.Printf("temperature collector soc_temp: %v", err)
|
|
}
|
|
}
|
|
|
|
res.Scale = site.solarScale()
|
|
|
|
return res
|
|
}
|
|
|
|
// effectiveSolarScale returns the solar forecast scale used to adjust the
|
|
// optimizer's solar input if forecast adjustment is enabled, 1 otherwise.
|
|
func (site *Site) effectiveSolarScale() float64 {
|
|
if !site.GetSolarAdjusted() {
|
|
return 1
|
|
}
|
|
return site.solarScale()
|
|
}
|
|
|
|
const (
|
|
solarScaleWindow = 30 // trailing window of days to consider
|
|
solarScaleMinSamples = 14 // minimum daily ratios before applying a scale
|
|
solarScalePercentile = 0.5 // percentile of the daily ratio distribution to use
|
|
solarScaleMinEnergy = 0.5 // kWh, skip days where either side is too small for a meaningful ratio
|
|
)
|
|
|
|
// solarScale computes a scale factor for the solar forecast by sorting the daily
|
|
// produced/forecasted solar ratio over a trailing window of completed days and
|
|
// picking the value at a configured percentile (window: solarScaleWindow,
|
|
// percentile: solarScalePercentile). This captures the installation's systematic
|
|
// bias (soiling, shading, model error) instead of a single day's weather noise.
|
|
// The current (partial) day is excluded; returns 1 when there is not enough history.
|
|
//
|
|
// Depends only on completed days, so it's cached instead of recomputed per run.
|
|
//
|
|
// The result only depends on completed days, so it cannot change within a day. It is
|
|
// cached accordingly instead of being recomputed on every optimizer run.
|
|
func (site *Site) solarScale() float64 {
|
|
scale, err := site.solarScaleCached()
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
return scale
|
|
}
|
|
|
|
// querySolarScale does the actual metrics query and percentile calculation
|
|
// for solarScale, given the current beginning-of-day boundary.
|
|
func (site *Site) querySolarScale(bod time.Time) (float64, error) {
|
|
from := bod.AddDate(0, 0, -solarScaleWindow)
|
|
series, err := metrics.QueryEnergy(from, time.Now(), "day", true)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
pv := make(map[string]float64, solarScaleWindow)
|
|
fcst := make(map[string]float64, solarScaleWindow)
|
|
for _, s := range series {
|
|
var m map[string]float64
|
|
switch s.Group {
|
|
case metrics.PV:
|
|
m = pv
|
|
case metrics.Forecast:
|
|
m = fcst
|
|
default:
|
|
continue
|
|
}
|
|
for _, d := range s.Data {
|
|
m[d.Start.Format("2006-01-02")] = d.Energy
|
|
}
|
|
}
|
|
|
|
today := bod.Format("2006-01-02")
|
|
ratios := make([]float64, 0, len(fcst))
|
|
for day, f := range fcst {
|
|
// skip today (partial) and dark days where the ratio is noise. The threshold
|
|
// applies to production as well: a near-zero yield against a healthy forecast
|
|
// is a fault (snow, soiling, inverter or metering outage), not a bias that
|
|
// should be projected onto the next solarScaleWindow days.
|
|
if p := pv[day]; day != today && f > solarScaleMinEnergy && p > solarScaleMinEnergy {
|
|
ratios = append(ratios, p/f)
|
|
}
|
|
}
|
|
|
|
scale, ok := percentileOf(ratios, solarScalePercentile, solarScaleMinSamples)
|
|
if !ok {
|
|
return 1, nil
|
|
}
|
|
site.log.DEBUG.Printf("solar scale P%.0f over %d days = %.3f", solarScalePercentile*100, len(ratios), scale)
|
|
return scale, nil
|
|
}
|
|
|
|
// percentileOf returns the p-th percentile (0..1) of values by nearest-rank on the
|
|
// sorted series, or false when fewer than minSamples are present.
|
|
func percentileOf(values []float64, p float64, minSamples int) (float64, bool) {
|
|
if len(values) < minSamples {
|
|
return 0, false
|
|
}
|
|
s := slices.Clone(values)
|
|
slices.Sort(s)
|
|
return s[int(p*float64(len(s)-1))], true
|
|
}
|
|
|
|
func (site *Site) isDynamicTariff(usage api.TariffUsage) bool {
|
|
tariff := site.GetTariff(usage)
|
|
return tariff != nil && tariff.Type() != api.TariffTypePriceStatic
|
|
}
|