Tariff: Add Stekker (Market prices + AI Forecast) (#23356)
This commit is contained in:
parent
a7bfab410b
commit
b58f3ae2eb
2 changed files with 218 additions and 0 deletions
164
tariff/stekker.go
Normal file
164
tariff/stekker.go
Normal 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=®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
|
||||
}
|
||||
54
templates/definition/tariff/stekker.yaml
Normal file
54
templates/definition/tariff/stekker.yaml
Normal 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" . }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue