-
-
Notifications
You must be signed in to change notification settings - Fork 668
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add carbonintensity.org.uk (National Grid ESO) CO2 forecasting (#9387)
- Loading branch information
1 parent
59fa929
commit 216dcba
Showing
5 changed files
with
337 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |