Add carbonintensity.org.uk (National Grid ESO) CO2 forecasting (#9387)

This commit is contained in:
duck 2023-08-31 20:10:27 +01:00 • committed by GitHub
parent 59fa929362
commit 216dcba3de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 337 additions and 0 deletions

View file

@ -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
View 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
View 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")

View 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()
}

View 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)
}
}