Skip to content

Commit

Permalink
Add carbonintensity.org.uk (National Grid ESO) CO2 forecasting (#9387)
Browse files Browse the repository at this point in the history
  • Loading branch information
duckfullstop authored Aug 31, 2023
1 parent 59fa929 commit 216dcba
Show file tree
Hide file tree
Showing 5 changed files with 337 additions and 0 deletions.
5 changes: 5 additions & 0 deletions evcc.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions tariff/ngeso.go
Original file line number Diff line number Diff line change
@@ -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 changes: 139 additions & 0 deletions tariff/ngeso/api.go
Original file line number Diff line number Diff line change
@@ -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 changes: 35 additions & 0 deletions tariff/ngeso/shortrfc3339.go
Original file line number Diff line number Diff line change
@@ -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 changes: 30 additions & 0 deletions tariff/ngeso/shortrfc3339_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit 216dcba

Please sign in to comment.