evcc-io/core/site_tariffs.go

283 lines
8.7 KiB
Go

package core
import (
"encoding/json"
"math"
"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"` // scale factor yield/forecasted today, 1 if unscaled
Today dailyDetails `json:"today,omitempty"` // tomorrow
Tomorrow dailyDetails `json:"tomorrow,omitempty"` // tomorrow
DayAfterTomorrow dailyDetails `json:"dayAfterTomorrow,omitempty"` // 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 {
greenPower := math.Max(0, site.pvPower) + math.Max(0, site.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)
}
}
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 r, err := solar.At(time.Now()); err == nil {
if err := site.collectors[metrics.Forecast].AddEnergy(nil, nil, r.Value); 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 if forecast adjustment
// is enabled, 1 otherwise.
func (site *Site) effectiveSolarScale() float64 {
if !site.GetSolarAdjusted() {
return 1
}
return site.solarScale()
}
// solarScale returns the ratio of produced solar energy to forecasted solar
// energy for the current day, queried from the metrics database. Used to
// adjust forecasts when PV is consistently under-/over-producing relative
// to the forecast. Returns 1.0 when not enough data is available to make
// the ratio meaningful.
func (site *Site) solarScale() float64 {
series, err := metrics.QueryEnergy(now.BeginningOfDay(), time.Now(), "day", true)
if err != nil {
site.log.ERROR.Printf("solar forecast scale: %v", err)
return 1
}
var pv, fcst float64
for _, s := range series {
if len(s.Data) == 0 {
continue
}
switch s.Group {
case metrics.PV:
pv = s.Data[0].Energy
case metrics.Forecast:
fcst = s.Data[0].Energy
}
}
const minEnergy = 0.5 // kWh
if fcst <= 0 || pv <= minEnergy {
return 1
}
scale := pv / fcst
site.log.DEBUG.Printf("solar forecast: produced %.3fkWh, forecasted %.3fkWh, scale %.3f", pv, fcst, scale)
return scale
}
func (site *Site) isDynamicTariff(usage api.TariffUsage) bool {
tariff := site.GetTariff(usage)
return tariff != nil && tariff.Type() != api.TariffTypePriceStatic
}