Skip to content

Commit b58f3ae

Browse files
authored
Tariff: Add Stekker (Market prices + AI Forecast) (#23356)
1 parent a7bfab4 commit b58f3ae

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

tariff/stekker.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package tariff
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"slices"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"github.com/PuerkitoBio/goquery"
13+
"github.com/evcc-io/evcc/api"
14+
"github.com/evcc-io/evcc/util"
15+
"github.com/evcc-io/evcc/util/request"
16+
)
17+
18+
// Supported regions
19+
var supportedRegions = []string{
20+
"BE", "NL", "DE-LU", "FR", "CH",
21+
"SE4", "SE3", "SE1", "DK1", "DK2",
22+
"FI", "NO1", "NO2", "NO3", "NO4", "NO5",
23+
"LV", "LT", "PL", "PT", "RO", "RS",
24+
"SI", "SK", "HU", "AT", "CZ", "HR", "EE",
25+
}
26+
27+
// Stekker provider
28+
type Stekker struct {
29+
*embed
30+
region string
31+
log *util.Logger
32+
data *util.Monitor[api.Rates]
33+
}
34+
35+
var _ api.Tariff = (*Stekker)(nil)
36+
37+
func init() {
38+
registry.Add("stekker", NewStekkerFromConfig)
39+
}
40+
41+
const stekkerURI = "https://stekker.app/epex-forecast"
42+
43+
// NewStekkerFromConfig creates provider from config
44+
func NewStekkerFromConfig(other map[string]interface{}) (api.Tariff, error) {
45+
var cc struct {
46+
embed `mapstructure:",squash"`
47+
Region string
48+
}
49+
50+
if err := util.DecodeOther(other, &cc); err != nil {
51+
return nil, err
52+
}
53+
54+
if !slices.Contains(supportedRegions, cc.Region) {
55+
return nil, fmt.Errorf("unsupported region: %s", cc.Region)
56+
}
57+
58+
t := &Stekker{
59+
embed: &cc.embed,
60+
region: cc.Region,
61+
log: util.NewLogger("stekker"),
62+
data: util.NewMonitor[api.Rates](2 * time.Hour),
63+
}
64+
65+
return runOrError(t)
66+
}
67+
68+
func (t *Stekker) run(done chan error) {
69+
var once sync.Once
70+
client := request.NewHelper(t.log)
71+
72+
for tick := time.Tick(time.Hour); ; <-tick {
73+
url := fmt.Sprintf("%s?advanced_view=&region=%s&unit=MWh", stekkerURI, t.region)
74+
75+
resp, err := client.Get(url)
76+
if err != nil {
77+
once.Do(func() { done <- err })
78+
t.log.ERROR.Println("http error:", err)
79+
continue
80+
}
81+
82+
if resp.StatusCode != http.StatusOK {
83+
once.Do(func() { done <- fmt.Errorf("http status %d", resp.StatusCode) })
84+
t.log.ERROR.Printf("http status %d", resp.StatusCode)
85+
resp.Body.Close()
86+
continue
87+
}
88+
89+
doc, err := goquery.NewDocumentFromReader(resp.Body)
90+
if err != nil {
91+
resp.Body.Close()
92+
once.Do(func() { done <- err })
93+
t.log.ERROR.Println("parse error:", err)
94+
continue
95+
}
96+
resp.Body.Close()
97+
98+
val, ok := doc.Find("[data-epex-forecast-graph-data-value]").Attr("data-epex-forecast-graph-data-value")
99+
if !ok {
100+
once.Do(func() { done <- fmt.Errorf("no forecast attribute found") })
101+
t.log.ERROR.Println("no forecast attribute found")
102+
continue
103+
}
104+
105+
raw := strings.ReplaceAll(val, "&quot;", "\"")
106+
107+
var data []map[string]interface{}
108+
if err := json.Unmarshal([]byte(raw), &data); err != nil {
109+
once.Do(func() { done <- err })
110+
t.log.ERROR.Println("unmarshal error:", err)
111+
continue
112+
}
113+
114+
var res api.Rates
115+
for _, series := range data {
116+
name, _ := series["name"].(string)
117+
if !(strings.Contains(name, "Market") || strings.Contains(name, "Forecast")) {
118+
continue
119+
}
120+
121+
xs, _ := series["x"].([]interface{})
122+
ys, _ := series["y"].([]interface{})
123+
124+
for i := range xs {
125+
xt, ok1 := xs[i].(string)
126+
yt, ok2 := ys[i].(float64)
127+
if !ok1 || !ok2 {
128+
continue
129+
}
130+
131+
start, err := time.Parse(time.RFC3339, xt)
132+
if err != nil {
133+
continue
134+
}
135+
136+
start = start.UTC()
137+
end := start.Add(time.Hour)
138+
139+
res = append(res, api.Rate{
140+
Start: start,
141+
End: end,
142+
Value: t.totalPrice(yt/1000.0, start), // €/MWh → €/kWh
143+
})
144+
}
145+
}
146+
147+
mergeRates(t.data, res)
148+
once.Do(func() { close(done) })
149+
}
150+
}
151+
152+
// Rates implements api.Tariff
153+
func (t *Stekker) Rates() (api.Rates, error) {
154+
var res api.Rates
155+
err := t.data.GetFunc(func(val api.Rates) {
156+
res = slices.Clone(val)
157+
})
158+
return res, err
159+
}
160+
161+
// Type implements api.Tariff
162+
func (t *Stekker) Type() api.TariffType {
163+
return api.TariffTypePriceForecast
164+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
template: stekker
2+
products:
3+
- brand: Stekker spot prices and AI Forecast
4+
requirements:
5+
description:
6+
en: "Stekker.app spot prices in day-ahead market and 24h AI price Forecast"
7+
evcc: ["skiptest"]
8+
group: price
9+
countries: ["EU"]
10+
params:
11+
- name: region
12+
example: BE
13+
type: choice
14+
choice:
15+
[
16+
"DE-LU",
17+
"EE",
18+
"LT",
19+
"LV",
20+
"AT",
21+
"BE",
22+
"FR",
23+
"GER",
24+
"NL",
25+
"PL",
26+
"DK1",
27+
"DK2",
28+
"FI",
29+
"NO1",
30+
"NO2",
31+
"NO3",
32+
"NO4",
33+
"NO5",
34+
"SE1",
35+
"SE2",
36+
"SE3",
37+
"SE4",
38+
"TEL",
39+
"SYS",
40+
"CH",
41+
"RO",
42+
"PT",
43+
"RS",
44+
"SI",
45+
"SK",
46+
"HU",
47+
"CZ",
48+
"HR",
49+
]
50+
- preset: tariff-base
51+
render: |
52+
type: stekker
53+
region: {{ .region }}
54+
{{ include "tariff-base" . }}

0 commit comments

Comments
 (0)