Add solar forecast using Solcast and Forecast.Solar (#18269)

This commit is contained in:
andig 2025-02-09 19:30:15 +01:00 • committed by GitHub
parent 7e8e182425
commit 01faac1d7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 415 additions and 90 deletions

View file

@ -140,6 +140,7 @@ type Tariffs struct {
FeedIn config.Typed
Co2 config.Typed
Planner config.Typed
Solar config.Typed
}
type Network struct {

View file

@ -1,6 +1,7 @@
package api
//go:generate enumer -type TariffType -trimprefix TariffType -transform=lower -text
//go:generate enumer -type TariffUsage -trimprefix TariffUsage -transform=lower
type TariffType int
@ -10,4 +11,16 @@ const (
TariffTypePriceDynamic
TariffTypePriceForecast
TariffTypeCo2
TariffTypeSolar
)
type TariffUsage int
const (
_ TariffUsage = iota
TariffUsageCo2
TariffUsageFeedin
TariffUsageGrid
TariffUsagePlanner
TariffUsageSolar
)

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2"
const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2solar"
var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39}
var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39, 44}
const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2"
const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2solar"
func (i TariffType) String() string {
i -= 1
@ -29,9 +29,10 @@ func _TariffTypeNoOp() {
_ = x[TariffTypePriceDynamic-(2)]
_ = x[TariffTypePriceForecast-(3)]
_ = x[TariffTypeCo2-(4)]
_ = x[TariffTypeSolar-(5)]
}
var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2}
var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2, TariffTypeSolar}
var _TariffTypeNameToValueMap = map[string]TariffType{
_TariffTypeName[0:11]: TariffTypePriceStatic,
@ -42,6 +43,8 @@ var _TariffTypeNameToValueMap = map[string]TariffType{
_TariffTypeLowerName[23:36]: TariffTypePriceForecast,
_TariffTypeName[36:39]: TariffTypeCo2,
_TariffTypeLowerName[36:39]: TariffTypeCo2,
_TariffTypeName[39:44]: TariffTypeSolar,
_TariffTypeLowerName[39:44]: TariffTypeSolar,
}
var _TariffTypeNames = []string{
@ -49,6 +52,7 @@ var _TariffTypeNames = []string{
_TariffTypeName[11:23],
_TariffTypeName[23:36],
_TariffTypeName[36:39],
_TariffTypeName[39:44],
}
// TariffTypeString retrieves an enum value from the enum constants string name.

91
api/tariffusage_enumer.go Normal file
View file

@ -0,0 +1,91 @@
// Code generated by "enumer -type TariffUsage -trimprefix TariffUsage -transform=lower"; DO NOT EDIT.
package api
import (
"fmt"
"strings"
)
const _TariffUsageName = "co2feedingridplannersolar"
var _TariffUsageIndex = [...]uint8{0, 3, 9, 13, 20, 25}
const _TariffUsageLowerName = "co2feedingridplannersolar"
func (i TariffUsage) String() string {
i -= 1
if i < 0 || i >= TariffUsage(len(_TariffUsageIndex)-1) {
return fmt.Sprintf("TariffUsage(%d)", i+1)
}
return _TariffUsageName[_TariffUsageIndex[i]:_TariffUsageIndex[i+1]]
}
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
func _TariffUsageNoOp() {
var x [1]struct{}
_ = x[TariffUsageCo2-(1)]
_ = x[TariffUsageFeedin-(2)]
_ = x[TariffUsageGrid-(3)]
_ = x[TariffUsagePlanner-(4)]
_ = x[TariffUsageSolar-(5)]
}
var _TariffUsageValues = []TariffUsage{TariffUsageCo2, TariffUsageFeedin, TariffUsageGrid, TariffUsagePlanner, TariffUsageSolar}
var _TariffUsageNameToValueMap = map[string]TariffUsage{
_TariffUsageName[0:3]: TariffUsageCo2,
_TariffUsageLowerName[0:3]: TariffUsageCo2,
_TariffUsageName[3:9]: TariffUsageFeedin,
_TariffUsageLowerName[3:9]: TariffUsageFeedin,
_TariffUsageName[9:13]: TariffUsageGrid,
_TariffUsageLowerName[9:13]: TariffUsageGrid,
_TariffUsageName[13:20]: TariffUsagePlanner,
_TariffUsageLowerName[13:20]: TariffUsagePlanner,
_TariffUsageName[20:25]: TariffUsageSolar,
_TariffUsageLowerName[20:25]: TariffUsageSolar,
}
var _TariffUsageNames = []string{
_TariffUsageName[0:3],
_TariffUsageName[3:9],
_TariffUsageName[9:13],
_TariffUsageName[13:20],
_TariffUsageName[20:25],
}
// TariffUsageString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func TariffUsageString(s string) (TariffUsage, error) {
if val, ok := _TariffUsageNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _TariffUsageNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to TariffUsage values", s)
}
// TariffUsageValues returns all values of the enum
func TariffUsageValues() []TariffUsage {
return _TariffUsageValues
}
// TariffUsageStrings returns a slice of all String values of the enum
func TariffUsageStrings() []string {
strs := make([]string, len(_TariffUsageNames))
copy(strs, _TariffUsageNames)
return strs
}
// IsATariffUsage returns "true" if the value is listed in the enum definition. "false" otherwise
func (i TariffUsage) IsATariffUsage() bool {
for _, v := range _TariffUsageValues {
if i == v {
return true
}
}
return false
}

View file

@ -755,11 +755,12 @@ func tariffInstance(name string, conf config.Typed) (api.Tariff, error) {
return instance, nil
}
func configureTariff(name string, conf config.Typed, t *api.Tariff) error {
func configureTariff(u api.TariffUsage, conf config.Typed, t *api.Tariff) error {
if conf.Type == "" {
return nil
}
name := u.String()
res, err := tariffInstance(name, conf)
if err != nil {
return &DeviceError{name, err}
@ -786,10 +787,11 @@ func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
}
var eg errgroup.Group
eg.Go(func() error { return configureTariff("grid", conf.Grid, &tariffs.Grid) })
eg.Go(func() error { return configureTariff("feedin", conf.FeedIn, &tariffs.FeedIn) })
eg.Go(func() error { return configureTariff("co2", conf.Co2, &tariffs.Co2) })
eg.Go(func() error { return configureTariff("planner", conf.Planner, &tariffs.Planner) })
eg.Go(func() error { return configureTariff(api.TariffUsageGrid, conf.Grid, &tariffs.Grid) })
eg.Go(func() error { return configureTariff(api.TariffUsageFeedin, conf.FeedIn, &tariffs.FeedIn) })
eg.Go(func() error { return configureTariff(api.TariffUsageCo2, conf.Co2, &tariffs.Co2) })
eg.Go(func() error { return configureTariff(api.TariffUsagePlanner, conf.Planner, &tariffs.Planner) })
eg.Go(func() error { return configureTariff(api.TariffUsageSolar, conf.Solar, &tariffs.Solar) })
if err := eg.Wait(); err != nil {
return nil, &ClassError{ClassTariff, err}

View file

@ -42,12 +42,14 @@ func runTariff(cmd *cobra.Command, args []string) {
name = args[0]
}
for key, tf := range map[string]api.Tariff{
"grid": tariffs.Grid,
"feedin": tariffs.FeedIn,
"co2": tariffs.Co2,
"planner": tariffs.Planner,
for u, tf := range map[api.TariffUsage]api.Tariff{
api.TariffUsageGrid: tariffs.Grid,
api.TariffUsageFeedin: tariffs.FeedIn,
api.TariffUsageCo2: tariffs.Co2,
api.TariffUsagePlanner: tariffs.Planner,
api.TariffUsageSolar: tariffs.Solar,
} {
key := u.String()
if name != "" && key != name {
continue
}
@ -65,8 +67,20 @@ func runTariff(cmd *cobra.Command, args []string) {
fatal(err)
}
unit := "Price/Cost"
switch tf.Type() {
case api.TariffTypeCo2:
unit += "Footprint (gCO2/kWh)"
case api.TariffTypeSolar:
unit = "Yield (W)"
default:
if c := conf.Tariffs.Currency; c != "" {
unit += fmt.Sprintf(" (%s/kWh)", c)
}
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0)
fmt.Fprintln(tw, "From\tTo\tPrice/Cost")
fmt.Fprintln(tw, "From\tTo\t"+unit)
const format = "2006-01-02 15:04:05"
for _, r := range rates {

View file

@ -26,6 +26,7 @@ const (
TariffGrid = "tariffGrid"
TariffPriceHome = "tariffPriceHome"
TariffPriceLoadpoints = "tariffPriceLoadpoints"
TariffSolar = "tariffSolar"
Vehicles = "vehicles"
// meters

View file

@ -151,7 +151,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
})
}
tariff := site.GetTariff(PlannerTariff)
tariff := site.GetTariff(api.TariffUsagePlanner)
// give loadpoints access to vehicles and database
for _, lp := range loadpoints {
@ -757,8 +757,8 @@ func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 {
// effectivePrice calculates the real energy price based on self-produced and grid-imported energy.
func (site *Site) effectivePrice(greenShare float64) *float64 {
if grid, err := site.tariffs.CurrentGridPrice(); err == nil {
feedin, err := site.tariffs.CurrentFeedInPrice()
if grid, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageGrid)); err == nil {
feedin, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageFeedin))
if err != nil {
feedin = 0
}
@ -770,7 +770,7 @@ func (site *Site) effectivePrice(greenShare float64) *float64 {
// 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 := site.tariffs.CurrentCo2(); err == nil {
if co2, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageCo2)); err == nil {
effCo2 := co2 * (1 - greenShare)
return &effCo2
}
@ -781,26 +781,29 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
site.publish(keys.GreenShareHome, greenShareHome)
site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints)
if gridPrice, err := site.tariffs.CurrentGridPrice(); err == nil {
site.publish(keys.TariffGrid, gridPrice)
if v, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageGrid)); err == nil {
site.publish(keys.TariffGrid, v)
}
if feedInPrice, err := site.tariffs.CurrentFeedInPrice(); err == nil {
site.publish(keys.TariffFeedIn, feedInPrice)
if v, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageFeedin)); err == nil {
site.publish(keys.TariffFeedIn, v)
}
if co2, err := site.tariffs.CurrentCo2(); err == nil {
site.publish(keys.TariffCo2, co2)
if v, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageCo2)); err == nil {
site.publish(keys.TariffCo2, v)
}
if price := site.effectivePrice(greenShareHome); price != nil {
site.publish(keys.TariffPriceHome, price)
if v, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageSolar)); err == nil {
site.publish(keys.TariffSolar, v)
}
if co2 := site.effectiveCo2(greenShareHome); co2 != nil {
site.publish(keys.TariffCo2Home, co2)
if v := site.effectivePrice(greenShareHome); v != nil {
site.publish(keys.TariffPriceHome, v)
}
if price := site.effectivePrice(greenShareLoadpoints); price != nil {
site.publish(keys.TariffPriceLoadpoints, price)
if v := site.effectiveCo2(greenShareHome); v != nil {
site.publish(keys.TariffCo2Home, v)
}
if co2 := site.effectiveCo2(greenShareLoadpoints); co2 != nil {
site.publish(keys.TariffCo2Loadpoints, co2)
if v := site.effectivePrice(greenShareLoadpoints); v != nil {
site.publish(keys.TariffPriceLoadpoints, v)
}
if v := site.effectiveCo2(greenShareLoadpoints); v != nil {
site.publish(keys.TariffCo2Loadpoints, v)
}
}
@ -926,7 +929,7 @@ func (site *Site) prepare() {
site.publish(keys.ResidualPower, site.GetResidualPower())
site.publish(keys.Currency, site.tariffs.Currency)
if tariff := site.GetTariff(PlannerTariff); tariff != nil {
if tariff := site.GetTariff(api.TariffUsagePlanner); tariff != nil {
site.publish(keys.SmartCostType, tariff.Type())
} else {
site.publish(keys.SmartCostType, nil)

View file

@ -55,7 +55,7 @@ type API interface {
//
// GetTariff returns the respective tariff
GetTariff(string) api.Tariff
GetTariff(api.TariffUsage) api.Tariff
//
// battery control

View file

@ -17,12 +17,6 @@ var _ site.API = (*Site)(nil)
var ErrBatteryNotConfigured = errors.New("battery not configured")
const (
GridTariff = "grid"
FeedinTariff = "feedin"
PlannerTariff = "planner"
)
// isConfigurable checks if the meter is configurable
func isConfigurable(ref string) bool {
dev, _ := config.Meters().ByName(ref)
@ -272,39 +266,10 @@ func (site *Site) SetResidualPower(power float64) error {
}
// GetTariff returns the respective tariff if configured or nil
func (site *Site) GetTariff(tariff string) api.Tariff {
func (site *Site) GetTariff(tariff api.TariffUsage) api.Tariff {
site.RLock()
defer site.RUnlock()
switch tariff {
case GridTariff:
return site.tariffs.Grid
case FeedinTariff:
return site.tariffs.FeedIn
case PlannerTariff:
switch {
case site.tariffs.Planner != nil:
// prio 0: manually set planner tariff
return site.tariffs.Planner
case site.tariffs.Grid != nil && site.tariffs.Grid.Type() == api.TariffTypePriceForecast:
// prio 1: grid tariff with forecast
return site.tariffs.Grid
case site.tariffs.Co2 != nil:
// prio 2: co2 tariff
return site.tariffs.Co2
default:
// prio 3: static grid tariff
return site.tariffs.Grid
}
default:
return nil
}
return site.tariffs.Get(tariff)
}
// GetBatteryDischargeControl returns the battery control mode (no discharge only)

View file

@ -78,7 +78,7 @@ func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
}
func (site *Site) plannerRates() (api.Rates, error) {
tariff := site.GetTariff(PlannerTariff)
tariff := site.GetTariff(api.TariffUsagePlanner)
if tariff == nil || tariff.Type() == api.TariffTypePriceStatic {
return nil, nil
}

View file

@ -251,7 +251,13 @@ func healthHandler(site site.API) http.HandlerFunc {
func tariffHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
tariff := vars["tariff"]
val := vars["tariff"]
tariff, err := api.TariffUsageString(val)
if err != nil {
jsonError(w, http.StatusNotFound, err)
return
}
t := site.GetTariff(tariff)
if t == nil {

121
tariff/solcast.go Normal file
View file

@ -0,0 +1,121 @@
package tariff
import (
"errors"
"fmt"
"slices"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/tariff/solcast"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
)
type Solcast struct {
*request.Helper
log *util.Logger
sites []string
data *util.Monitor[api.Rates]
}
var _ api.Tariff = (*Solcast)(nil)
func init() {
registry.Add("solcast", NewSolcastFromConfig)
}
func NewSolcastFromConfig(other map[string]interface{}) (api.Tariff, error) {
var cc struct {
Site string
Token string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.Site == "" {
return nil, errors.New("missing site id")
}
// TODO multiple sites
// if len(cc.Site) > 1 {
// return nil, errors.New("multiple sites not supported (yet)")
// }
if cc.Token == "" {
return nil, errors.New("missing token")
}
log := util.NewLogger("solcast").Redact(cc.Token)
t := &Solcast{
log: log,
sites: []string{cc.Site},
Helper: request.NewHelper(log),
data: util.NewMonitor[api.Rates](2 * time.Hour),
}
t.Client.Transport = transport.BearerAuth(cc.Token, t.Client.Transport)
done := make(chan error)
go t.run(done)
err := <-done
return t, err
}
func (t *Solcast) run(done chan error) {
var once sync.Once
for ; true; <-time.Tick(time.Hour) {
var res solcast.Forecasts
if err := backoff.Retry(func() error {
for _, site := range t.sites {
uri := fmt.Sprintf("https://api.solcast.com.au/rooftop_sites/%s/forecasts?format=json", site)
if err := t.GetJSON(uri, &res); err != nil {
return err
}
}
return nil
}, bo()); err != nil {
once.Do(func() { done <- err })
t.log.ERROR.Println(err)
continue
}
once.Do(func() { close(done) })
data := make(api.Rates, 0, len(res.Forecasts))
for _, r := range res.Forecasts {
data = append(data, api.Rate{
Start: r.PeriodEnd.Add(-r.Period.Duration()).Local(),
End: r.PeriodEnd.Local(),
Price: r.PvEstimate * 1e3,
})
}
mergeRates(t.data, data)
once.Do(func() { close(done) })
}
}
// Rates implements the api.Tariff interface
func (t *Solcast) Rates() (api.Rates, error) {
var res api.Rates
err := t.data.GetFunc(func(val api.Rates) {
res = slices.Clone(val)
})
return res, err
}
// Type implements the api.Tariff interface
func (t *Solcast) Type() api.TariffType {
return api.TariffTypeSolar
}

34
tariff/solcast/types.go Normal file
View file

@ -0,0 +1,34 @@
package solcast
import (
"time"
"github.com/dylanmei/iso8601"
)
type Forecasts struct {
Forecasts []Forecast
}
type Forecast struct {
PvEstimate float64 `json:"pv_estimate"`
PvEstimate10 float64 `json:"pv_estimate10"`
PvEstimate90 float64 `json:"pv_estimate90"`
PeriodEnd time.Time `json:"period_end"`
Period Duration
}
type Duration time.Duration
func (d *Duration) Duration() time.Duration {
return time.Duration(*d)
}
func (d *Duration) UnmarshalJSON(b []byte) error {
val, err := iso8601.ParseDuration(string(b))
if err != nil {
return err
}
*d = Duration(val)
return nil
}

View file

@ -8,11 +8,11 @@ import (
)
type Tariffs struct {
Currency currency.Unit
Grid, FeedIn, Co2, Planner api.Tariff
Currency currency.Unit
Grid, FeedIn, Co2, Planner, Solar api.Tariff
}
func currentPrice(t api.Tariff) (float64, error) {
func CurrentPrice(t api.Tariff) (float64, error) {
if t != nil {
if rr, err := t.Rates(); err == nil {
if r, err := rr.Current(time.Now()); err == nil {
@ -23,20 +23,41 @@ func currentPrice(t api.Tariff) (float64, error) {
return 0, api.ErrNotAvailable
}
// CurrentGridPrice returns the current grid price.
func (t *Tariffs) CurrentGridPrice() (float64, error) {
return currentPrice(t.Grid)
}
func (t *Tariffs) Get(u api.TariffUsage) api.Tariff {
switch u {
case api.TariffUsageCo2:
return t.Co2
// CurrentFeedInPrice returns the current feed-in price.
func (t *Tariffs) CurrentFeedInPrice() (float64, error) {
return currentPrice(t.FeedIn)
}
case api.TariffUsageFeedin:
return t.FeedIn
// CurrentCo2 determines the grids co2 emission.
func (t *Tariffs) CurrentCo2() (float64, error) {
if t.Co2 != nil {
return currentPrice(t.Co2)
case api.TariffUsageGrid:
return t.Grid
// TODO solar
case api.TariffUsagePlanner:
switch {
case t.Planner != nil:
// prio 0: manually set planner tariff
return t.Planner
case t.Grid != nil && t.Grid.Type() == api.TariffTypePriceForecast:
// prio 1: grid tariff with forecast
return t.Grid
case t.Co2 != nil:
// prio 2: co2 tariff
return t.Co2
default:
// prio 3: static grid tariff
return t.Grid
}
case api.TariffUsageSolar:
return t.Solar
default:
return nil
}
return 0, api.ErrNotAvailable
}

View file

@ -0,0 +1,31 @@
template: forecast-solar
products:
- brand: Forecast.Solar
requirements:
evcc: ["skiptest"]
group: solar
params:
- name: lat
required: true
- name: lon
required: true
- name: dec
required: true
- name: az
required: true
- name: kwp
required: true
- name: apikey
advanced: true
render: |
type: custom
tariff: solar
forecast:
source: http
uri: https://api.forecast.solar/{{ if .apikey }}{{ .apikey }}/{{ end }}estimate/{{ .lat }}/{{ .lon }}/{{ .dec }}/{{ .az }}/{{ .kwp }}?time=utc&no_sun=1
jq: |
[ .result.watts | to_entries.[] | {
"start": (.key | strptime("%FT%T%z") | strftime("%FT%TZ")),
"end": (.key | strptime("%FT%T%z") | mktime+3600 | strftime("%FT%TZ")),
"price": .value
} ] | tostring

View file

@ -0,0 +1,15 @@
template: solcast
products:
- brand: Solcast
requirements:
evcc: ["skiptest"]
group: solar
params:
- name: site
required: true
- name: token
required: true
render: |
type: solcast
site: {{ .site }}
token: {{ .token }}

View file

@ -606,3 +606,6 @@ devicegroups:
co2:
de: CO₂ Vorhersage
en: CO₂ forecast
solar:
de: PV Vorhersage
en: PV forecast