evcc-io/tariff/pun.go
2026-07-01 21:53:10 +02:00

259 lines
5.9 KiB
Go

package tariff
import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
)
// ErrPunDataNotAvailable indicates that GME has not yet published prices for the requested day.
var ErrPunDataNotAvailable = errors.New("PUN data not available")
// romeLocation is resolved once at package init to avoid repeated filesystem lookups.
var romeLocation *time.Location
type Pun struct {
*embed
zone string
log *util.Logger
data *util.Monitor[api.Rates]
}
type NewDataSet struct {
XMLName xml.Name `xml:"NewDataSet"`
Prezzi []Prezzo `xml:"Prezzi"`
}
type Zone struct {
XMLName xml.Name
Value string `xml:",chardata"`
}
type Prezzo struct {
Data string `xml:"Data"`
Ora string `xml:"Ora"`
Zones []Zone `xml:",any"`
}
type Rate struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Price float64 `json:"price"`
}
var _ api.Tariff = (*Pun)(nil)
func init() {
registry.Add("pun", NewPunFromConfig)
romeLocation, _ = time.LoadLocation("Europe/Rome")
}
func NewPunFromConfig(other map[string]any) (api.Tariff, error) {
var cc struct {
embed `mapstructure:",squash"`
Zone string
}
logger := util.NewLogger("pun")
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if err := cc.init(); err != nil {
return nil, err
}
var zone = strings.ToUpper(strings.TrimSpace(cc.Zone))
if cc.Zone == "" {
zone = "PUN"
}
t := &Pun{
log: logger,
zone: zone,
embed: &cc.embed,
data: util.NewMonitor[api.Rates](2 * time.Hour),
}
return runOrError(t)
}
func (t *Pun) run(done chan error) {
var once sync.Once
for tick := time.Tick(time.Hour); ; <-tick {
// get today data
today, err := backoff.RetryWithData(func() (api.Rates, error) {
res, err := t.getData(time.Now())
return res, backoffPermanentError(err)
}, bo())
if err != nil {
if reportError(&once, done, err) {
return
}
t.log.ERROR.Println(err)
continue
}
// get tomorrow data (may not be available before ~13:00 CET)
res, err := backoff.RetryWithData(func() (api.Rates, error) {
res, err := t.getData(time.Now().AddDate(0, 0, 1))
if errors.Is(err, ErrPunDataNotAvailable) {
return res, backoff.Permanent(err)
}
return res, backoffPermanentError(err)
}, bo())
if err != nil && !errors.Is(err, ErrPunDataNotAvailable) {
if reportError(&once, done, err) {
return
}
t.log.ERROR.Println(err)
continue
}
// merge today and tomorrow data
data := append(today, res...)
mergeRates(t.data, data)
once.Do(func() { close(done) })
}
}
// Rates implements the api.Tariff interface
func (t *Pun) 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 *Pun) Type() api.TariffType {
return api.TariffTypePriceForecast
}
func (t *Pun) priceForZone(p Prezzo) (float64, error) {
for _, z := range p.Zones {
if strings.ToUpper(strings.TrimSpace(z.XMLName.Local)) == t.zone {
price, err := strconv.ParseFloat(strings.ReplaceAll(z.Value, ",", "."), 64)
return price, err
}
}
return 0, fmt.Errorf("zone %s not found for hour %s", t.zone, p.Ora)
}
func (t *Pun) getData(day time.Time) (api.Rates, error) {
client := request.NewClient(t.log)
client.Jar, _ = cookiejar.New(nil)
// Request the ZIP file
uri := "https://gme.mercatoelettrico.org/DesktopModules/GmeDownload/API/ExcelDownload/downloadzipfile?DataInizio=" + day.Format("20060102") + "&DataFine=" + day.Format("20060102") + "&Date=" + day.Format("20060102") + "&Mercato=MGP&Settore=Prezzi&FiltroDate=InizioFine"
req, _ := http.NewRequest("GET", uri, nil)
req.Header = http.Header{
"Referer": {"https://gme.mercatoelettrico.org/en-us/Home/Results/Electricity/MGP/Download?valore=Prezzi"},
"moduleid": {"12103"},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {"Windows"},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
"sec-gpc": {"1"},
"tabid": {"1749"},
"userid": {"-1"},
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%w: %s", ErrPunDataNotAvailable, day.Format("2006-01-02"))
}
body, err := request.ReadBody(resp)
if err != nil {
return nil, err
}
zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
return nil, err
}
var tariffFile *zip.File
for _, file := range zipReader.File {
if strings.HasSuffix(file.Name, "Prezzi.xml") {
tariffFile = file
break
}
}
if tariffFile == nil {
return nil, fmt.Errorf("tariff file not found in downloaded ZIP archive")
}
f, err := tariffFile.Open()
if err != nil {
return nil, err
}
defer f.Close()
// Process the received data
var dataSet NewDataSet
if err := xml.NewDecoder(f).Decode(&dataSet); err != nil {
return nil, err
}
data := make(api.Rates, 0, len(dataSet.Prezzi))
for _, p := range dataSet.Prezzi {
date, err := time.Parse("20060102", p.Data)
if err != nil {
return nil, fmt.Errorf("parse date: %w", err)
}
hour, err := strconv.Atoi(p.Ora)
if err != nil {
return nil, fmt.Errorf("parse hour: %w", err)
}
// Adjust hour to handle edge case where p.Ora is "00"
if hour == 0 {
hour = 24
date = date.AddDate(0, 0, -1)
}
price, err := t.priceForZone(p)
if err != nil {
return nil, fmt.Errorf("parse price: %w", err)
}
ts := time.Date(date.Year(), date.Month(), date.Day(), hour-1, 0, 0, 0, romeLocation)
ar := api.Rate{
Start: ts,
End: ts.Add(time.Hour),
Value: t.totalPrice(price/1e3, ts),
}
data = append(data, ar)
}
data.Sort()
return data, nil
}