Add carbonintensity.org.uk (National Grid ESO) CO2 forecasting (#9387)
This commit is contained in:
parent
59fa929362
commit
216dcba3de
5 changed files with 337 additions and 0 deletions
|
|
@ -206,6 +206,11 @@ tariffs:
|
|||
# token: <token>
|
||||
# zone: DE
|
||||
|
||||
# type: ngeso # National Grid Electricity System Operator data (United Kingdom only) https://carbonintensity.org.uk/
|
||||
# provides national data if both region and postcode are omitted - do not supply both at the same time!
|
||||
# region: 1 # optional, coarser than using a postcode - see https://api.carbonintensity.org.uk/ for full list
|
||||
# postcode: SW1A1AA # optional
|
||||
|
||||
# mqtt message broker
|
||||
mqtt:
|
||||
# broker: localhost:1883
|
||||
|
|
|
|||
128
tariff/ngeso.go
Normal file
128
tariff/ngeso.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package tariff
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/tariff/ngeso"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type Ngeso struct {
|
||||
mux sync.Mutex
|
||||
log *util.Logger
|
||||
regionId string
|
||||
regionPostcode string
|
||||
data api.Rates
|
||||
updated time.Time
|
||||
}
|
||||
|
||||
var _ api.Tariff = (*Ngeso)(nil)
|
||||
|
||||
func init() {
|
||||
registry.Add("ngeso", NewNgesoFromConfig)
|
||||
}
|
||||
|
||||
func NewNgesoFromConfig(other map[string]interface{}) (api.Tariff, error) {
|
||||
var cc struct {
|
||||
Region string
|
||||
Postcode string
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.Region != "" && cc.Postcode != "" {
|
||||
return nil, errors.New("cannot define region and postcode simultaneously")
|
||||
}
|
||||
|
||||
t := &Ngeso{
|
||||
log: util.NewLogger("ngeso"),
|
||||
regionId: cc.Region,
|
||||
regionPostcode: cc.Postcode,
|
||||
}
|
||||
|
||||
done := make(chan error)
|
||||
go t.run(done)
|
||||
err := <-done
|
||||
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (t *Ngeso) run(done chan error) {
|
||||
var once sync.Once
|
||||
client := request.NewHelper(t.log)
|
||||
bo := newBackoff()
|
||||
|
||||
// Use national results by default.
|
||||
var tReq ngeso.CarbonForecastRequest
|
||||
tReq = ngeso.ConstructNationalForecastRequest()
|
||||
|
||||
// If a region is available, use that.
|
||||
// These should never be set simultaneously (see NewNgesoFromConfig), but in the rare case that they are,
|
||||
// use the postcode as the preferred method.
|
||||
if t.regionId != "" {
|
||||
tReq = ngeso.ConstructRegionalForecastByIDRequest(t.regionId)
|
||||
}
|
||||
if t.regionPostcode != "" {
|
||||
tReq = ngeso.ConstructRegionalForecastByPostcodeRequest(t.regionPostcode)
|
||||
}
|
||||
|
||||
// Data updated by ESO every half hour, but we only need data every hour to stay current.
|
||||
for ; true; <-time.Tick(time.Hour) {
|
||||
var carbonResponse ngeso.CarbonForecastResponse
|
||||
if err := backoff.Retry(func() error {
|
||||
var err error
|
||||
carbonResponse, err = tReq.DoRequest(client)
|
||||
|
||||
// Consider whether errors.As would be more appropriate if this needs to start dealing with wrapped errors.
|
||||
if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusBadRequest) {
|
||||
// Catch cases where we're sending completely incorrect data (usually the result of a bad region).
|
||||
return backoff.Permanent(se)
|
||||
}
|
||||
return err
|
||||
}, bo); err != nil {
|
||||
once.Do(func() { done <- err })
|
||||
|
||||
t.log.ERROR.Println(err)
|
||||
continue
|
||||
}
|
||||
|
||||
once.Do(func() { close(done) })
|
||||
|
||||
t.mux.Lock()
|
||||
t.updated = time.Now()
|
||||
|
||||
t.data = make(api.Rates, 0, len(carbonResponse.Results()))
|
||||
for _, r := range carbonResponse.Results() {
|
||||
ar := api.Rate{
|
||||
Start: r.ValidityStart.Time,
|
||||
End: r.ValidityEnd.Time,
|
||||
// Use the forecasted rate, as the actual rate is only available for historical data
|
||||
Price: r.Intensity.Forecast,
|
||||
}
|
||||
t.data = append(t.data, ar)
|
||||
}
|
||||
|
||||
t.mux.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Rates implements the api.Tariff interface
|
||||
func (t *Ngeso) Rates() (api.Rates, error) {
|
||||
t.mux.Lock()
|
||||
defer t.mux.Unlock()
|
||||
return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
|
||||
}
|
||||
|
||||
// Type implements the api.Tariff interface
|
||||
func (t *Ngeso) Type() api.TariffType {
|
||||
return api.TariffTypeCo2
|
||||
}
|
||||
139
tariff/ngeso/api.go
Normal file
139
tariff/ngeso/api.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Package ngeso implements the carbonintensity.org.uk Grid CO2 tracking service, which provides CO2 forecasting for the UK at a national and regional level.
|
||||
// This service is provided by the National Grid Electricity System Operator (NGESO).
|
||||
package ngeso
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BaseURI is the root path that the API is accessed from.
|
||||
const BaseURI = "https://api.carbonintensity.org.uk/"
|
||||
|
||||
// ForecastNationalURI defines the location of the national forecast.
|
||||
// Replace the first %s with the RFC3339 timestamp to fetch from.
|
||||
const ForecastNationalURI = BaseURI + "intensity/%s/fw48h"
|
||||
|
||||
// ForecastRegionalByIdURI defines the location of the regional forecast determined by Region ID.
|
||||
// Replace the first %s with the RFC3339 timestamp to fetch from, and the second with the appropriate Region ID.
|
||||
const ForecastRegionalByIdURI = BaseURI + "regional/intensity/%s/fw48h/regionid/%s"
|
||||
|
||||
// ForecastRegionalByPostcodeURI defines the location of the regional forecast determined by a given postcode.
|
||||
// Replace the first %s with the RFC3339 timestamp to fetch from, and the second with the appropriate postcode.
|
||||
const ForecastRegionalByPostcodeURI = BaseURI + "regional/intensity/%s/fw48h/postcode/%s"
|
||||
|
||||
// ConstructNationalForecastRequest returns a request object to be used when calling the national API.
|
||||
func ConstructNationalForecastRequest() *CarbonForecastNationalRequest {
|
||||
return &CarbonForecastNationalRequest{}
|
||||
}
|
||||
|
||||
// ConstructRegionalForecastByIDRequest returns a validly formatted, fully qualified URI to the forecast valid for the given region.
|
||||
func ConstructRegionalForecastByIDRequest(r string) *CarbonForecastRegionalRequest {
|
||||
return &CarbonForecastRegionalRequest{regionid: r}
|
||||
}
|
||||
|
||||
// ConstructRegionalForecastByPostcodeRequest returns a validly formatted, fully qualified URI to the forecast valid for the given postcode.
|
||||
func ConstructRegionalForecastByPostcodeRequest(p string) *CarbonForecastRegionalRequest {
|
||||
return &CarbonForecastRegionalRequest{postcode: p}
|
||||
}
|
||||
|
||||
type CarbonForecastRequest interface {
|
||||
URI() (string, error)
|
||||
DoRequest(helper *request.Helper) (CarbonForecastResponse, error)
|
||||
}
|
||||
|
||||
type CarbonForecastNationalRequest struct{}
|
||||
|
||||
func (r *CarbonForecastNationalRequest) URI() (string, error) {
|
||||
return fmt.Sprintf(ForecastNationalURI, time.Now().UTC().Format(time.RFC3339)), nil
|
||||
}
|
||||
|
||||
func (r *CarbonForecastNationalRequest) DoRequest(client *request.Helper) (CarbonForecastResponse, error) {
|
||||
uri, err := r.URI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res NationalIntensityResult
|
||||
err = client.GetJSON(uri, res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
type CarbonForecastRegionalRequest struct {
|
||||
regionid string
|
||||
postcode string
|
||||
}
|
||||
|
||||
func (r *CarbonForecastRegionalRequest) URI() (string, error) {
|
||||
currentTs := time.Now().UTC().Format(time.RFC3339)
|
||||
// Prefer postcode to Region ID
|
||||
if r.postcode != "" {
|
||||
return fmt.Sprintf(ForecastRegionalByPostcodeURI, currentTs, r.postcode), nil
|
||||
}
|
||||
if r.regionid != "" {
|
||||
return fmt.Sprintf(ForecastRegionalByIdURI, currentTs, r.regionid), nil
|
||||
}
|
||||
|
||||
// One of the region identifiers must be supplied, if neither are then just return an error
|
||||
return "", ErrRegionalRequestInvalidFormat
|
||||
}
|
||||
|
||||
func (r *CarbonForecastRegionalRequest) DoRequest(client *request.Helper) (CarbonForecastResponse, error) {
|
||||
uri, err := r.URI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := &RegionalIntensityResult{}
|
||||
err = client.GetJSON(uri, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
type CarbonForecastResponse interface {
|
||||
Results() []CarbonIntensityForecastEntry
|
||||
}
|
||||
|
||||
// RegionalIntensityResult is returned by Regional requests. It wraps all data inside a data element.
|
||||
// Because that makes sense, and makes all of this SO much easier. /s
|
||||
type RegionalIntensityResult struct {
|
||||
Data RegionalIntensityResultData `json:"data"`
|
||||
}
|
||||
|
||||
func (r RegionalIntensityResult) Results() []CarbonIntensityForecastEntry {
|
||||
return r.Data.Rates
|
||||
}
|
||||
|
||||
// RegionalIntensityResultData is returned by Regional requests. It includes a bit of extra data.
|
||||
type RegionalIntensityResultData struct {
|
||||
RegionId int `json:"regionid"`
|
||||
DNORegion string `json:"dnoregion"`
|
||||
ShortName string `json:"shortname"`
|
||||
Rates []CarbonIntensityForecastEntry `json:"data"`
|
||||
}
|
||||
|
||||
// NationalIntensityResult is returned either as a sub-element of a Regional request, or as the main result of a National request.
|
||||
type NationalIntensityResult struct {
|
||||
Rates []CarbonIntensityForecastEntry `json:"data"`
|
||||
}
|
||||
|
||||
// Results is a helper / interface function to return the current rate data.
|
||||
func (r NationalIntensityResult) Results() []CarbonIntensityForecastEntry {
|
||||
return r.Rates
|
||||
}
|
||||
|
||||
type CarbonIntensityForecastEntry struct {
|
||||
ValidityStart shortRFC3339Timestamp `json:"from"`
|
||||
ValidityEnd shortRFC3339Timestamp `json:"to"`
|
||||
Intensity CarbonIntensity `json:"intensity"`
|
||||
}
|
||||
|
||||
type CarbonIntensity struct {
|
||||
// The forecasted rate in gCO2/kWh
|
||||
Forecast float64 `json:"forecast"`
|
||||
// The rate recorded when this slot occurred - only available historically, otherwise nil
|
||||
Actual float64 `json:"actual"`
|
||||
// A human-readable representation of the level of emissions (e.g "low", "moderate")
|
||||
Index string `json:"index"`
|
||||
}
|
||||
|
||||
var ErrRegionalRequestInvalidFormat = errors.New("regional request object missing region")
|
||||
35
tariff/ngeso/shortrfc3339.go
Normal file
35
tariff/ngeso/shortrfc3339.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package ngeso
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// shortRFC3339Timestamp is a custom JSON encoder / decoder for shortened RFC3339-compliant timestamps (those without seconds).
|
||||
// Please don't ask why NGESO uses this format instead of standards-compliant RFC3339. 🇬🇧
|
||||
type shortRFC3339Timestamp struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
const s3339Layout = "2006-01-02T15:04Z"
|
||||
|
||||
func (ct *shortRFC3339Timestamp) UnmarshalJSON(b []byte) (err error) {
|
||||
s := strings.Trim(string(b), "\"")
|
||||
if s == "null" {
|
||||
ct.Time = time.Time{}
|
||||
return
|
||||
}
|
||||
ct.Time, err = time.Parse(s3339Layout, s)
|
||||
return
|
||||
}
|
||||
|
||||
func (ct *shortRFC3339Timestamp) MarshalJSON() ([]byte, error) {
|
||||
if ct.Time.IsZero() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(ct.Time.Format(s3339Layout)), nil
|
||||
}
|
||||
|
||||
func (ct *shortRFC3339Timestamp) IsSet() bool {
|
||||
return !ct.IsZero()
|
||||
}
|
||||
30
tariff/ngeso/shortrfc3339_test.go
Normal file
30
tariff/ngeso/shortrfc3339_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package ngeso
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
tTsBytes = []byte("2023-04-20T14:30Z")
|
||||
tTsStringRepr = "2023-04-20 14:30:00 +0000 UTC"
|
||||
)
|
||||
|
||||
func TestMarshalling(t *testing.T) {
|
||||
// Firstly, test that we can unmarshal into a struct.
|
||||
ct := shortRFC3339Timestamp{}
|
||||
if err := ct.UnmarshalJSON(tTsBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ct.Time.String() != tTsStringRepr {
|
||||
t.Errorf("time did not unmarshal successfully: got %s, expected %s", ct.Time.String(), tTsStringRepr)
|
||||
}
|
||||
|
||||
// Now test remarshalling.
|
||||
res, err := ct.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(res) != string(tTsBytes) {
|
||||
t.Errorf("time did not marshal successfully: got %s, expected %s", res, tTsBytes)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue