Repository: evcc-io/evcc
Pull request #9387 (2023-08-15 16:13:43)

Add carbonintensity.org.uk (National Grid ESO) CO2 forecasting
This supports the National Grid Electricity System Operator's carbonintensity.org.uk service, supplying information on forecast CO2 per kWh.

Extremely sloppy first commit, just wanted to get something out the door - tests and a bit more refinement to come.

(oh my goodness is their API a mess, what have I gotten myself into?)

GrimmiMeloni: Wow, timestamps without seconds. Guess they really saved a few nibbles here and there. I feel your pain .... ;)
andig: @duckfullstop do you want to finish this?
duckfullstop: Apologies for the delay, been dealing with personal life stuff 😞

I'll try my best to spend a little more time this week refactoring it, but if I'm not able to carve some time out before the weekend it'll probably be best for me to drop it and either let someone else take it on, or stick it on my backburner.

Patch:
diff --git a/charger/easee.go b/charger/easee.go
index 941925f8..8c085c08 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -207,7 +207,7 @@ func (c *Easee) chargerSite(charger string) (easee.Site, error) {
 // connect creates an HTTP connection to the signalR hub
 func (c *Easee) connect(ts oauth2.TokenSource) func() (signalr.Connection, error) {
 	bo := backoff.NewExponentialBackOff()
-	bo.MaxElapsedTime = time.Minute
+	bo.MaxInterval = time.Minute
 
 	return func() (conn signalr.Connection, err error) {
 		defer func() {
@@ -447,7 +447,7 @@ func (c *Easee) inExpectedOpMode(enable bool) bool {
 	c.mux.Lock()
 	defer c.mux.Unlock()
 
-	//start/resume
+	// start/resume
 	if enable {
 		return c.opMode == easee.ModeCharging ||
 			c.opMode == easee.ModeCompleted ||
@@ -455,7 +455,7 @@ func (c *Easee) inExpectedOpMode(enable bool) bool {
 			c.opMode == easee.ModeReadyToCharge
 	}
 
-	//paused/stopped
+	// paused/stopped
 	return c.opMode == easee.ModeAwaitingStart || c.opMode == easee.ModeAwaitingAuthentication
 }
 
diff --git a/charger/hardybarth-salia.go b/charger/hardybarth-salia.go
index 533247d5..7fe10e48 100644
--- a/charger/hardybarth-salia.go
+++ b/charger/hardybarth-salia.go
@@ -119,7 +119,7 @@ func NewSalia(uri string, cache time.Duration) (api.Charger, error) {
 func (wb *Salia) heartbeat() {
 	bo := backoff.NewExponentialBackOff()
 	bo.InitialInterval = 5 * time.Second
-	bo.MaxElapsedTime = time.Minute
+	bo.MaxInterval = time.Minute
 
 	for range time.Tick(30 * time.Second) {
 		if err := backoff.Retry(func() error {
diff --git a/charger/helper.go b/charger/helper.go
index c176cb26..69a7cfad 100644
--- a/charger/helper.go
+++ b/charger/helper.go
@@ -4,6 +4,8 @@ import (
 	"bytes"
 	"fmt"
 	"strings"
+
+	"github.com/evcc-io/evcc/api"
 )
 
 // ensureCharger extracts VIN from list of VINs returned from `list` function
@@ -52,3 +54,15 @@ func ensureChargerWithFeature[Charger, Feature any](
 func bytesAsString(b []byte) string {
 	return strings.TrimSpace(string(bytes.TrimLeft(b, "\x00")))
 }
+
+// verifyEnabled validates the enabled state against the charger status
+func verifyEnabled(c api.Charger, enabled bool) (bool, error) {
+	if enabled {
+		return true, nil
+	}
+
+	status, err := c.Status()
+
+	// always treat charging as enabled
+	return status == api.StatusC, err
+}
diff --git a/charger/openwb.go b/charger/openwb.go
index 49256438..4b0d765d 100644
--- a/charger/openwb.go
+++ b/charger/openwb.go
@@ -177,7 +177,12 @@ func (m *OpenWB) Enable(enable bool) error {
 }
 
 func (m *OpenWB) Enabled() (bool, error) {
-	return m.enabled, nil
+	enabled, err := verifyEnabled(m, m.enabled)
+	if err == nil {
+		m.enabled = enabled
+	}
+
+	return enabled, err
 }
 
 func (m *OpenWB) Status() (api.ChargeStatus, error) {
diff --git a/charger/twc3.go b/charger/twc3.go
index faaacdff..c086ca9b 100644
--- a/charger/twc3.go
+++ b/charger/twc3.go
@@ -30,31 +30,31 @@ func init() {
 
 // Vitals is the /api/1/vitals response
 type Vitals struct {
-	ContactorClosed   bool    `json:"contactor_closed"`    //false
-	VehicleConnected  bool    `json:"vehicle_connected"`   //false
-	SessionS          int64   `json:"session_s"`           //0
-	GridV             float64 `json:"grid_v"`              //230.1
-	GridHz            float64 `json:"grid_hz"`             //49.928
-	VehicleCurrentA   float64 `json:"vehicle_current_a"`   //0.1
-	CurrentAA         float64 `json:"currentA_a"`          //0.0
-	CurrentBA         float64 `json:"currentB_a"`          //0.1
-	CurrentCA         float64 `json:"currentC_a"`          //0.0
-	CurrentNA         float64 `json:"currentN_a"`          //0.0
-	VoltageAV         float64 `json:"voltageA_v"`          //0.0
-	VoltageBV         float64 `json:"voltageB_v"`          //0.0
-	VoltageCV         float64 `json:"voltageC_v"`          //0.0
-	RelayCoilV        float64 `json:"relay_coil_v"`        //11.8
-	PcbaTempC         float64 `json:"pcba_temp_c"`         //19.2
-	HandleTempC       float64 `json:"handle_temp_c"`       //15.3
-	McuTempC          float64 `json:"mcu_temp_c"`          //25.1
-	UptimeS           int     `json:"uptime_s"`            //831580
+	ContactorClosed   bool    `json:"contactor_closed"`    // false
+	VehicleConnected  bool    `json:"vehicle_connected"`   // false
+	SessionS          int64   `json:"session_s"`           // 0
+	GridV             float64 `json:"grid_v"`              // 230.1
+	GridHz            float64 `json:"grid_hz"`             // 49.928
+	VehicleCurrentA   float64 `json:"vehicle_current_a"`   // 0.1
+	CurrentAA         float64 `json:"currentA_a"`          // 0.0
+	CurrentBA         float64 `json:"currentB_a"`          // 0.1
+	CurrentCA         float64 `json:"currentC_a"`          // 0.0
+	CurrentNA         float64 `json:"currentN_a"`          // 0.0
+	VoltageAV         float64 `json:"voltageA_v"`          // 0.0
+	VoltageBV         float64 `json:"voltageB_v"`          // 0.0
+	VoltageCV         float64 `json:"voltageC_v"`          // 0.0
+	RelayCoilV        float64 `json:"relay_coil_v"`        // 11.8
+	PcbaTempC         float64 `json:"pcba_temp_c"`         // 19.2
+	HandleTempC       float64 `json:"handle_temp_c"`       // 15.3
+	McuTempC          float64 `json:"mcu_temp_c"`          // 25.1
+	UptimeS           int     `json:"uptime_s"`            // 831580
 	InputThermopileUv float64 `json:"input_thermopile_uv"` //-233
-	ProxV             float64 `json:"prox_v"`              //0.0
-	PilotHighV        float64 `json:"pilot_high_v"`        //11.9
-	PilotLowV         float64 `json:"pilot_low_v"`         //11.9
-	SessionEnergyWh   float64 `json:"session_energy_wh"`   //22864.699
-	ConfigStatus      int     `json:"config_status"`       //5
-	EvseState         int     `json:"evse_state"`          //1
+	ProxV             float64 `json:"prox_v"`              // 0.0
+	PilotHighV        float64 `json:"pilot_high_v"`        // 11.9
+	PilotLowV         float64 `json:"pilot_low_v"`         // 11.9
+	SessionEnergyWh   float64 `json:"session_energy_wh"`   // 22864.699
+	ConfigStatus      int     `json:"config_status"`       // 5
+	EvseState         int     `json:"evse_state"`          // 1
 	CurrentAlerts     []any   `json:"current_alerts"`      //[]
 }
 
@@ -91,7 +91,12 @@ func NewTwc3FromConfig(other map[string]interface{}) (api.Charger, error) {
 
 // Enabled implements the api.Charger interface
 func (c *Twc3) Enabled() (bool, error) {
-	return c.enabled, nil
+	enabled, err := verifyEnabled(c, c.enabled)
+	if err == nil {
+		c.enabled = enabled
+	}
+
+	return enabled, err
 }
 
 // Enable implements the api.Charger interface
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 9e0e438a..178ed9ab 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -646,6 +646,7 @@ func (lp *Loadpoint) syncCharger() error {
 		}
 		enabled = true // treat as enabled when charging
 		lp.elapseGuard()
+		lp.elapsePVTimer()
 		return nil
 	}
 
diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go
index 8a06d086..ed5faf37 100644
--- a/core/loadpoint_plan.go
+++ b/core/loadpoint_plan.go
@@ -1,6 +1,7 @@
 package core
 
 import (
+	"fmt"
 	"slices"
 	"time"
 
@@ -87,9 +88,14 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 	planStart := planner.Start(plan)
 	lp.publish(planProjectedStart, planStart)
 
-	lp.log.DEBUG.Printf("planned %v until %v at %.0fW: total plan duration: %v, avg cost: %.3f",
-		requiredDuration.Round(time.Second), lp.targetTime.Round(time.Second).Local(), maxPower,
-		planner.Duration(plan).Round(time.Second), planner.AverageCost(plan))
+	var requiredString string
+	if req := requiredDuration.Round(time.Second); req > planner.Duration(plan).Round(time.Second) {
+		requiredString = fmt.Sprintf(" (required: %v)", req)
+	}
+
+	lp.log.DEBUG.Printf("plan: charge %v%s starting at %v until %v (power: %.0fW, avg cost: %.3f)",
+		planner.Duration(plan).Round(time.Second), requiredString, planStart.Round(time.Second).Local(), lp.targetTime.Round(time.Second).Local(),
+		maxPower, planner.AverageCost(plan))
 
 	// log plan
 	for _, slot := range plan {
@@ -102,7 +108,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 	if active {
 		// ignore short plans if not already active
 		if slotRemaining := lp.clock.Until(activeSlot.End); !lp.planActive && slotRemaining < smallSlotDuration && !planner.SlotHasSuccessor(activeSlot, plan) {
-			lp.log.DEBUG.Printf("plan slot too short- ignoring remaining %v", slotRemaining.Round(time.Second))
+			lp.log.DEBUG.Printf("plan: slot too short- ignoring remaining %v", slotRemaining.Round(time.Second))
 			return false
 		}
 
@@ -115,17 +121,17 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 		case lp.clock.Now().After(lp.targetTime) && !lp.targetTime.IsZero():
 			// if the plan did not (entirely) work, we may still be charging beyond plan end- in that case, continue charging
 			// TODO check when schedule is implemented
-			lp.log.DEBUG.Println("continuing after target time")
+			lp.log.DEBUG.Println("plan: continuing after target time")
 			return true
 		case lp.clock.Now().Before(lp.planSlotEnd) && !lp.planSlotEnd.IsZero():
 			// don't stop an already running slot if goal was not met
-			lp.log.DEBUG.Println("continuing until end of slot")
+			lp.log.DEBUG.Println("plan: continuing until end of slot")
 			return true
 		case requiredDuration < smallGapDuration:
-			lp.log.DEBUG.Printf("continuing for remaining %v", requiredDuration.Round(time.Second))
+			lp.log.DEBUG.Printf("plan: continuing for remaining %v", requiredDuration.Round(time.Second))
 			return true
 		case lp.clock.Until(planStart) < smallGapDuration:
-			lp.log.DEBUG.Printf("plan will re-start shortly, continuing for remaining %v", lp.clock.Until(planStart).Round(time.Second))
+			lp.log.DEBUG.Printf("plan: will re-start shortly, continuing for remaining %v", lp.clock.Until(planStart).Round(time.Second))
 			return true
 		}
 	}
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 9994460d..f395573e 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -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
diff --git a/meter/dsmr.go b/meter/dsmr.go
index 1e5324cf..d7b4ee40 100644
--- a/meter/dsmr.go
+++ b/meter/dsmr.go
@@ -139,9 +139,8 @@ func NewDsmr(uri, energy string, timeout time.Duration) (api.Meter, error) {
 // based on https://github.com/basvdlei/gotsmart/blob/master/gotsmart.go
 func (m *Dsmr) run(conn net.Conn, done chan struct{}) {
 	log := util.NewLogger("dsmr")
-	backoff := backoff.NewExponentialBackOff()
-	backoff.InitialInterval = time.Second
-	backoff.MaxInterval = 5 * time.Minute
+	bo := backoff.NewExponentialBackOff()
+	bo.MaxInterval = 5 * time.Minute
 
 	handle := func(op string, err error) {
 		log.ERROR.Printf("%s: %v", op, err)
@@ -161,7 +160,7 @@ func (m *Dsmr) run(conn net.Conn, done chan struct{}) {
 			conn, err = m.connect()
 			if err != nil {
 				handle("connect", err)
-				time.Sleep(backoff.NextBackOff().Truncate(time.Second))
+				time.Sleep(bo.NextBackOff().Truncate(time.Second))
 				continue
 			}
 
@@ -169,7 +168,7 @@ func (m *Dsmr) run(conn net.Conn, done chan struct{}) {
 		}
 
 		if b, err := reader.Peek(1); err == nil {
-			backoff.Reset()
+			bo.Reset()
 
 			if string(b) != "/" {
 				log.DEBUG.Printf("ignoring garbage character: %c\n", b)
@@ -178,7 +177,7 @@ func (m *Dsmr) run(conn net.Conn, done chan struct{}) {
 			}
 		} else {
 			handle("peek", err)
-			time.Sleep(backoff.NextBackOff().Truncate(time.Second))
+			time.Sleep(bo.NextBackOff().Truncate(time.Second))
 			continue
 		}
 
diff --git a/meter/rct.go b/meter/rct.go
index 35bfaf63..ce317440 100644
--- a/meter/rct.go
+++ b/meter/rct.go
@@ -83,8 +83,8 @@ func NewRCT(uri, usage string, cache time.Duration, capacity func() float64) (ap
 	}
 
 	bo := backoff.NewExponentialBackOff()
-	bo.MaxElapsedTime = time.Second
 	bo.InitialInterval = 10 * time.Millisecond
+	bo.MaxInterval = time.Second
 
 	m := &RCT{
 		usage: strings.ToLower(usage),
diff --git a/tariff/helper.go b/tariff/helper.go
index d20bfb3a..2f9e0485 100644
--- a/tariff/helper.go
+++ b/tariff/helper.go
@@ -8,7 +8,7 @@ import (
 
 func newBackoff() backoff.BackOff {
 	bo := backoff.NewExponentialBackOff()
-	bo.InitialInterval = 5 * time.Second
-	bo.MaxElapsedTime = time.Minute
+	bo.InitialInterval = time.Second
+	bo.MaxInterval = time.Minute
 	return bo
 }
diff --git a/tariff/ngeso.go b/tariff/ngeso.go
new file mode 100644
index 00000000..5db8de87
--- /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 00000000..f93ed14e
--- /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 00000000..5f4051bc
--- /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()
+}


Test patch:
diff --git a/tariff/ngeso/shortrfc3339_test.go b/tariff/ngeso/shortrfc3339_test.go
new file mode 100644
index 00000000..b27c53cb
--- /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)
+	}
+}