From 216dcba3de6a241fcdadd0d0d6adebef424d4943 Mon Sep 17 00:00:00 2001 From: duck Date: Thu, 31 Aug 2023 20:10:27 +0100 Subject: [PATCH] Add carbonintensity.org.uk (National Grid ESO) CO2 forecasting (#9387) --- evcc.dist.yaml | 5 ++ tariff/ngeso.go | 128 +++++++++++++++++++++++++++ tariff/ngeso/api.go | 139 ++++++++++++++++++++++++++++++ tariff/ngeso/shortrfc3339.go | 35 ++++++++ tariff/ngeso/shortrfc3339_test.go | 30 +++++++ 5 files changed, 337 insertions(+) create mode 100644 tariff/ngeso.go create mode 100644 tariff/ngeso/api.go create mode 100644 tariff/ngeso/shortrfc3339.go create mode 100644 tariff/ngeso/shortrfc3339_test.go diff --git a/evcc.dist.yaml b/evcc.dist.yaml index 9994460dba..f395573eb0 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -206,6 +206,11 @@ tariffs: # 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 diff --git a/tariff/ngeso.go b/tariff/ngeso.go new file mode 100644 index 0000000000..5db8de8738 --- /dev/null +++ b/tariff/ngeso.go @@ -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 +} diff --git a/tariff/ngeso/api.go b/tariff/ngeso/api.go new file mode 100644 index 0000000000..f93ed14ea4 --- /dev/null +++ b/tariff/ngeso/api.go @@ -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") diff --git a/tariff/ngeso/shortrfc3339.go b/tariff/ngeso/shortrfc3339.go new file mode 100644 index 0000000000..5f4051bcaa --- /dev/null +++ b/tariff/ngeso/shortrfc3339.go @@ -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() +} diff --git a/tariff/ngeso/shortrfc3339_test.go b/tariff/ngeso/shortrfc3339_test.go new file mode 100644 index 0000000000..b27c53cb5f --- /dev/null +++ b/tariff/ngeso/shortrfc3339_test.go @@ -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) + } +}