Tariff: Add Stekker (Market prices + AI Forecast) (#23356)

This commit is contained in:
fanatix 2025-09-14 19:16:32 +02:00 • committed by GitHub
parent a7bfab410b
commit b58f3ae2eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 218 additions and 0 deletions

164
tariff/stekker.go Normal file
View file

@ -0,0 +1,164 @@
package tariff
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
)
// Supported regions
var supportedRegions = []string{
"BE", "NL", "DE-LU", "FR", "CH",
"SE4", "SE3", "SE1", "DK1", "DK2",
"FI", "NO1", "NO2", "NO3", "NO4", "NO5",
"LV", "LT", "PL", "PT", "RO", "RS",
"SI", "SK", "HU", "AT", "CZ", "HR", "EE",
}
// Stekker provider
type Stekker struct {
*embed
region string
log *util.Logger
data *util.Monitor[api.Rates]
}
var _ api.Tariff = (*Stekker)(nil)
func init() {
registry.Add("stekker", NewStekkerFromConfig)
}
const stekkerURI = "https://stekker.app/epex-forecast"
// NewStekkerFromConfig creates provider from config
func NewStekkerFromConfig(other map[string]interface{}) (api.Tariff, error) {
var cc struct {
embed `mapstructure:",squash"`
Region string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if !slices.Contains(supportedRegions, cc.Region) {
return nil, fmt.Errorf("unsupported region: %s", cc.Region)
}
t := &Stekker{
embed: &cc.embed,
region: cc.Region,
log: util.NewLogger("stekker"),
data: util.NewMonitor[api.Rates](2 * time.Hour),
}
return runOrError(t)
}
func (t *Stekker) run(done chan error) {
var once sync.Once
client := request.NewHelper(t.log)
for tick := time.Tick(time.Hour); ; <-tick {
url := fmt.Sprintf("%s?advanced_view=&region=%s&unit=MWh", stekkerURI, t.region)
resp, err := client.Get(url)
if err != nil {
once.Do(func() { done <- err })
t.log.ERROR.Println("http error:", err)
continue
}
if resp.StatusCode != http.StatusOK {
once.Do(func() { done <- fmt.Errorf("http status %d", resp.StatusCode) })
t.log.ERROR.Printf("http status %d", resp.StatusCode)
resp.Body.Close()
continue
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
resp.Body.Close()
once.Do(func() { done <- err })
t.log.ERROR.Println("parse error:", err)
continue
}
resp.Body.Close()
val, ok := doc.Find("[data-epex-forecast-graph-data-value]").Attr("data-epex-forecast-graph-data-value")
if !ok {
once.Do(func() { done <- fmt.Errorf("no forecast attribute found") })
t.log.ERROR.Println("no forecast attribute found")
continue
}
raw := strings.ReplaceAll(val, "&quot;", "\"")
var data []map[string]interface{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
once.Do(func() { done <- err })
t.log.ERROR.Println("unmarshal error:", err)
continue
}
var res api.Rates
for _, series := range data {
name, _ := series["name"].(string)
if !(strings.Contains(name, "Market") || strings.Contains(name, "Forecast")) {
continue
}
xs, _ := series["x"].([]interface{})
ys, _ := series["y"].([]interface{})
for i := range xs {
xt, ok1 := xs[i].(string)
yt, ok2 := ys[i].(float64)
if !ok1 || !ok2 {
continue
}
start, err := time.Parse(time.RFC3339, xt)
if err != nil {
continue
}
start = start.UTC()
end := start.Add(time.Hour)
res = append(res, api.Rate{
Start: start,
End: end,
Value: t.totalPrice(yt/1000.0, start), // €/MWh → €/kWh
})
}
}
mergeRates(t.data, res)
once.Do(func() { close(done) })
}
}
// Rates implements api.Tariff
func (t *Stekker) 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 api.Tariff
func (t *Stekker) Type() api.TariffType {
return api.TariffTypePriceForecast
}

View file

@ -0,0 +1,54 @@
template: stekker
products:
- brand: Stekker spot prices and AI Forecast
requirements:
description:
en: "Stekker.app spot prices in day-ahead market and 24h AI price Forecast"
evcc: ["skiptest"]
group: price
countries: ["EU"]
params:
- name: region
example: BE
type: choice
choice:
[
"DE-LU",
"EE",
"LT",
"LV",
"AT",
"BE",
"FR",
"GER",
"NL",
"PL",
"DK1",
"DK2",
"FI",
"NO1",
"NO2",
"NO3",
"NO4",
"NO5",
"SE1",
"SE2",
"SE3",
"SE4",
"TEL",
"SYS",
"CH",
"RO",
"PT",
"RS",
"SI",
"SK",
"HU",
"CZ",
"HR",
]
- preset: tariff-base
render: |
type: stekker
region: {{ .region }}
{{ include "tariff-base" . }}