From b58f3ae2ebb8b50ebf2a2d6e49cca34ddbc6d9a3 Mon Sep 17 00:00:00 2001 From: fanatix <43752712+djfanatix@users.noreply.github.com> Date: Sun, 14 Sep 2025 19:16:32 +0200 Subject: [PATCH] Tariff: Add Stekker (Market prices + AI Forecast) (#23356) --- tariff/stekker.go | 164 +++++++++++++++++++++++ templates/definition/tariff/stekker.yaml | 54 ++++++++ 2 files changed, 218 insertions(+) create mode 100644 tariff/stekker.go create mode 100644 templates/definition/tariff/stekker.yaml diff --git a/tariff/stekker.go b/tariff/stekker.go new file mode 100644 index 000000000..ad46ed875 --- /dev/null +++ b/tariff/stekker.go @@ -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=®ion=%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, """, "\"") + + 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 +} diff --git a/templates/definition/tariff/stekker.yaml b/templates/definition/tariff/stekker.yaml new file mode 100644 index 000000000..a86288404 --- /dev/null +++ b/templates/definition/tariff/stekker.yaml @@ -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" . }}