feat: solar export metrics (#16)
Some checks failed
Release / release (push) Has been cancelled

This commit is contained in:
Rasmus Wejlgaard 2026-05-25 11:56:21 +01:00 committed by GitHub
parent 00f1b29ab0
commit dbcf50eb13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 260 additions and 22 deletions

View file

@ -28,6 +28,18 @@ Gas metrics are only exposed if a smart gas meter is found on the account.
| `octopus_gas_unit_rate_pence` | GraphQL | Current unit rate in pence per kWh |
| `octopus_gas_standing_charge_pence` | GraphQL | Current standing charge in pence per day |
### Solar / export
Solar metrics are only exposed if an electricity export meter point (direction `EXPORT`) is found on the account. Solar export usually shares the physical meter and smart device with the import meter, so live export watts come from the same telemetry call as electricity demand (no extra API request).
| Metric | Source | Description |
|---|---|---|
| `octopus_solar_export_watts` | GraphQL | Live electricity export to the grid in watts |
| `octopus_solar_last_read_timestamp` | GraphQL | Unix timestamp of last solar export reading |
| `octopus_solar_export_kwh` | REST | Latest half-hourly energy exported to the grid in kWh |
| `octopus_solar_export_interval_timestamp` | REST | Unix timestamp of the start of the latest export interval |
| `octopus_solar_export_rate_pence` | GraphQL | Current export (outgoing) unit rate in pence per kWh |
### Account
| Metric | Source | Description |
@ -58,6 +70,9 @@ Metrics are refreshed every `POLL_INTERVAL` (default 60 seconds).
| `OCTOPUS_GAS_MPRN` | No | Filter gas meter by MPRN |
| `OCTOPUS_GAS_SERIAL` | No | Filter gas meter by serial number |
| `OCTOPUS_GAS_DEVICE_ID` | No | Use a specific gas smart device ID directly |
| `OCTOPUS_SOLAR_MPAN` | No | Filter solar export meter by MPAN |
| `OCTOPUS_SOLAR_SERIAL` | No | Filter solar export meter by serial number |
| `OCTOPUS_SOLAR_DEVICE_ID` | No | Use a specific solar export smart device ID directly |
| `PORT` | No | Port to expose metrics on (default: `9359`) |
| `POLL_INTERVAL` | No | How often to poll Octopus APIs (Go duration, default: `60s`) |

View file

@ -21,9 +21,11 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
path = fmt.Sprintf("/v1/gas-meter-points/%s/meters/%s/consumption/", id, serial)
}
// Consumption data can lag several hours, so use a 24h window and take the latest entry.
// Consumption data can lag significantly — meters that report once a day can be
// 2448h behind — so use a wide window and take the latest entry. The interval
// timestamp metric tells consumers how stale the latest reading actually is.
result, err := doREST(path, url.Values{
"period_from": {time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)},
"period_from": {time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339)},
"order_by": {"period"},
}, key)
if err != nil {
@ -32,7 +34,7 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
results := toSlice(result["results"])
if len(results) == 0 {
return nil, errors.New("no consumption data in last 24h")
return nil, errors.New("no consumption data in last 7 days")
}
latest, ok := results[len(results)-1].(map[string]any)

View file

@ -92,6 +92,14 @@ func main() {
log.Println("no gas smart meter found — gas metrics disabled")
}
solarMeter, err := resolveMeter(candidates, solar)
if err != nil {
log.Fatalf("failed to resolve solar export meter: %v", err)
}
if solarMeter == nil {
log.Println("no solar export meter found — solar metrics disabled")
}
// --- Metrics ---
// Electricity telemetry (live, from GraphQL)
@ -149,6 +157,22 @@ func main() {
toRegister = append(toRegister, gasDemand, gasLastRead, gasConsumption, gasConsumptionInterval, gasUnitRate, gasStandCharge)
}
var (
solarExportWatts prometheus.Gauge
solarLastRead prometheus.Gauge
solarExport prometheus.Gauge
solarExportInterval prometheus.Gauge
solarExportRate prometheus.Gauge
)
if solarMeter != nil {
solarExportWatts = gauge("octopus_solar_export_watts", "Live electricity export (solar) to the grid in watts")
solarLastRead = gauge("octopus_solar_last_read_timestamp", "Unix timestamp of last solar export reading")
solarExport = gauge("octopus_solar_export_kwh", "Half-hourly electricity exported to the grid in kWh")
solarExportInterval = gauge("octopus_solar_export_interval_timestamp", "Unix timestamp of the start of the latest solar export interval")
solarExportRate = gauge("octopus_solar_export_rate_pence", "Current export (outgoing) unit rate in pence per kWh")
toRegister = append(toRegister, solarExportWatts, solarLastRead, solarExport, solarExportInterval, solarExportRate)
}
prometheus.MustRegister(toRegister...)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@ -229,6 +253,11 @@ func main() {
}()
}
// Solar export shares the physical meter/device with the import meter on
// most installs, so we can read live export watts from the same telemetry
// call rather than spending a second request.
solarSharesDevice := solarMeter != nil && solarMeter.deviceID != "" && solarMeter.deviceID == elecMeter.deviceID
// Electricity telemetry (live demand)
if elecMeter.deviceID != "" {
collect("electricity telemetry", func() error {
@ -241,6 +270,29 @@ func main() {
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
elecLastRead.Set(float64(ts.Unix()))
}
if solarSharesDevice {
solarExportWatts.Set(float64(reading.Export))
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
solarLastRead.Set(float64(ts.Unix()))
}
}
return nil
})
})
}
// Solar telemetry on a distinct export device (rare).
if solarMeter != nil && solarMeter.deviceID != "" && !solarSharesDevice {
collect("solar telemetry", func() error {
return withToken(func(t string) error {
reading, err := getLiveConsumption(t, solarMeter.deviceID)
if err != nil {
return err
}
solarExportWatts.Set(float64(reading.Export))
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
solarLastRead.Set(float64(ts.Unix()))
}
return nil
})
})
@ -289,6 +341,19 @@ func main() {
})
}
// Solar half-hourly export (REST; uses the electricity meter-point path with the export MPAN)
if solarMeter != nil && solarMeter.mpan != "" && solarMeter.serial != "" {
collect("solar export", func() error {
c, err := getLatestConsumption(electricity, solarMeter.mpan, solarMeter.serial, apiKey)
if err != nil {
return err
}
solarExport.Set(c.KWh)
solarExportInterval.Set(float64(c.IntervalStart.Unix()))
return nil
})
}
// Tariff rates (result needed for optional agile lookup after wg.Wait)
var collectedRates *tariffRates
collect("rates", func() error {
@ -333,6 +398,9 @@ func main() {
gasUnitRate.Set(collectedRates.GasUnitRate)
gasStandCharge.Set(collectedRates.GasStandingCharge)
}
if solarMeter != nil && collectedRates.SolarHasExport {
solarExportRate.Set(collectedRates.SolarExportRate)
}
}
if failedAny.Load() {

View file

@ -11,6 +11,9 @@ type meterKind string
const (
electricity meterKind = "electricity"
gas meterKind = "gas"
// solar is an electricity export meter point (direction EXPORT) — a separate
// MPAN that usually shares the physical meter/device with the import meter.
solar meterKind = "solar"
)
type meterCandidate struct {
@ -33,6 +36,7 @@ func getMeters(token string) ([]meterCandidate, error) {
Query: `{ viewer { accounts { ... on AccountType { properties {
electricityMeterPoints {
mpan
direction
meters {
serialNumber
smartDevices { deviceId }
@ -70,6 +74,12 @@ func getMeters(token string) ([]meterCandidate, error) {
continue
}
mpan, _ := mpPoint["mpan"].(string)
// EXPORT meter points are solar generation export; everything else
// (IMPORT or unknown/legacy with no direction) is grid import.
kind := electricity
if dir, _ := mpPoint["direction"].(string); dir == "EXPORT" {
kind = solar
}
for _, m := range toSlice(mpPoint["meters"]) {
meterMap, ok := m.(map[string]any)
if !ok {
@ -84,12 +94,12 @@ func getMeters(token string) ([]meterCandidate, error) {
}
deviceID, _ := dMap["deviceId"].(string)
if deviceID != "" {
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial, deviceID: deviceID})
candidates = append(candidates, meterCandidate{kind: kind, mpan: mpan, serial: serial, deviceID: deviceID})
}
}
// Include meters without smart devices so we can still use the REST consumption endpoint.
if len(devices) == 0 && serial != "" {
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial})
candidates = append(candidates, meterCandidate{kind: kind, mpan: mpan, serial: serial})
}
}
}
@ -141,6 +151,10 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
wantDeviceID = os.Getenv("OCTOPUS_GAS_DEVICE_ID")
wantID = os.Getenv("OCTOPUS_GAS_MPRN")
wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL")
case solar:
wantDeviceID = os.Getenv("OCTOPUS_SOLAR_DEVICE_ID")
wantID = os.Getenv("OCTOPUS_SOLAR_MPAN")
wantSerial = os.Getenv("OCTOPUS_SOLAR_SERIAL")
}
for _, c := range candidates {
@ -151,7 +165,7 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
continue
}
if wantID != "" {
if kind == electricity && c.mpan != wantID {
if (kind == electricity || kind == solar) && c.mpan != wantID {
continue
}
if kind == gas && c.mprn != wantID {
@ -168,6 +182,8 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
case gas:
log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", m.mprn, m.serial, m.deviceID)
case solar:
log.Printf("using solar export meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
}
return m, nil
}

View file

@ -173,6 +173,56 @@ func TestGetMeters_MeterWithoutSmartDevice(t *testing.T) {
}
}
func TestGetMeters_ExportClassifiedAsSolar(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
"electricityMeterPoints":[
{"mpan":"1900000000001","direction":"EXPORT","meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]},
{"mpan":"1900000000002","direction":"IMPORT","meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]}
],
"gasMeterPoints":[]
}]}]}}}`)
}))
defer srv.Close()
octopusGraphQL = srv.URL + "/"
candidates, err := getMeters("token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
byKind := map[meterKind]meterCandidate{}
for _, c := range candidates {
byKind[c.kind] = c
}
if byKind[electricity].mpan != "1900000000002" {
t.Errorf("electricity mpan: got %q, want 1900000000002", byKind[electricity].mpan)
}
if byKind[solar].mpan != "1900000000001" {
t.Errorf("solar mpan: got %q, want 1900000000001", byKind[solar].mpan)
}
}
func TestResolveMeter_Solar(t *testing.T) {
t.Setenv("OCTOPUS_SOLAR_DEVICE_ID", "")
t.Setenv("OCTOPUS_SOLAR_MPAN", "")
t.Setenv("OCTOPUS_SOLAR_SERIAL", "")
candidates := []meterCandidate{
{kind: electricity, mpan: "1900000000002", serial: "A001", deviceID: "dev1"},
{kind: solar, mpan: "1900000000001", serial: "A001", deviceID: "dev1"},
}
m, err := resolveMeter(candidates, solar)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m == nil {
t.Fatal("expected solar meter, got nil")
}
if m.mpan != "1900000000001" {
t.Errorf("got mpan %q, want 1900000000001", m.mpan)
}
}
func TestGetMeters_BothElectricityAndGas(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{

View file

@ -1,5 +1,7 @@
package main
import "time"
type tariffRates struct {
ElectricityUnitRate float64
ElectricityStandingCharge float64
@ -8,20 +10,23 @@ type tariffRates struct {
ElectricityIsAgile bool
GasUnitRate float64
GasStandingCharge float64
SolarExportRate float64
SolarHasExport bool
}
func getRates(token string) (*tariffRates, error) {
result, err := doGraphQL(gqlRequest{
Query: `{ viewer { accounts { ... on AccountType { properties {
electricityMeterPoints {
agreements { validTo tariff {
direction
agreements { validFrom validTo tariff {
... on StandardTariff { unitRate standingCharge productCode tariffCode }
... on HalfHourlyTariff { standingCharge productCode tariffCode }
... on PrepayTariff { unitRate standingCharge productCode tariffCode }
} }
}
gasMeterPoints {
agreements { validTo tariff { unitRate standingCharge } }
agreements { validFrom validTo tariff { unitRate standingCharge } }
}
} } } } }`,
}, token)
@ -43,7 +48,16 @@ func getRates(token string) (*tariffRates, error) {
}
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
if tariff := activeAgreementTariff(mp); tariff != nil {
tariff := activeAgreementTariff(mp)
if tariff == nil {
continue
}
mpm, _ := mp.(map[string]any)
if dir, _ := mpm["direction"].(string); dir == "EXPORT" {
rates.SolarExportRate, _ = tariff["unitRate"].(float64)
rates.SolarHasExport = true
continue
}
rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64)
rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(float64)
rates.ElectricityProductCode, _ = tariff["productCode"].(string)
@ -53,7 +67,6 @@ func getRates(token string) (*tariffRates, error) {
rates.ElectricityIsAgile = true
}
}
}
for _, mp := range toSlice(pm["gasMeterPoints"]) {
if tariff := activeAgreementTariff(mp); tariff != nil {
@ -67,21 +80,33 @@ func getRates(token string) (*tariffRates, error) {
return rates, nil
}
// activeAgreementTariff returns the tariff map for the agreement with validTo == null.
// activeAgreementTariff returns the tariff for the agreement in effect now:
// validFrom in the past (or absent) and validTo null or in the future. Fixed-term
// tariffs carry a future validTo (their contract end), so matching only validTo==null
// would miss them.
func activeAgreementTariff(meterPoint any) map[string]any {
mp, ok := meterPoint.(map[string]any)
if !ok {
return nil
}
now := time.Now()
for _, ag := range toSlice(mp["agreements"]) {
agm, ok := ag.(map[string]any)
if !ok {
continue
}
if agm["validTo"] == nil {
if from, ok := agm["validFrom"].(string); ok {
if t, err := time.Parse(time.RFC3339, from); err == nil && t.After(now) {
continue // not started yet
}
}
if to, ok := agm["validTo"].(string); ok {
if t, err := time.Parse(time.RFC3339, to); err == nil && !t.After(now) {
continue // already ended
}
}
tariff, _ := agm["tariff"].(map[string]any)
return tariff
}
}
return nil
}

View file

@ -23,6 +23,33 @@ func TestActiveAgreementTariff_ActiveFound(t *testing.T) {
}
}
func TestActiveAgreementTariff_FixedTermFutureValidTo(t *testing.T) {
// Fixed-term tariffs carry a future validTo (contract end), not null.
mp := map[string]any{
"agreements": []any{
map[string]any{"validFrom": "2020-01-01T00:00:00Z", "validTo": "2099-01-01T00:00:00Z", "tariff": map[string]any{"unitRate": 23.37}},
},
}
tariff := activeAgreementTariff(mp)
if tariff == nil {
t.Fatal("expected tariff for in-effect fixed-term agreement, got nil")
}
if tariff["unitRate"].(float64) != 23.37 {
t.Errorf("got unitRate %v, want 23.37", tariff["unitRate"])
}
}
func TestActiveAgreementTariff_NotYetStarted(t *testing.T) {
mp := map[string]any{
"agreements": []any{
map[string]any{"validFrom": "2099-01-01T00:00:00Z", "validTo": nil, "tariff": map[string]any{"unitRate": 99.0}},
},
}
if tariff := activeAgreementTariff(mp); tariff != nil {
t.Errorf("expected nil for not-yet-started agreement, got %v", tariff)
}
}
func TestActiveAgreementTariff_NoneActive(t *testing.T) {
mp := map[string]any{
"agreements": []any{
@ -95,6 +122,40 @@ func TestGetRates_AgileTariff(t *testing.T) {
}
}
func TestGetRates_SplitsImportAndExport(t *testing.T) {
// Export meter point listed first (as the live API returns it) must not
// clobber the import unit rate.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
"electricityMeterPoints":[
{"direction":"EXPORT","agreements":[{"validTo":null,"tariff":{
"unitRate":12.0,"standingCharge":0.0,"productCode":"OUTGOING-VAR-24-10-26","tariffCode":"E-1R-OUTGOING-VAR-24-10-26-J"
}}]},
{"direction":"IMPORT","agreements":[{"validFrom":"2020-01-01T00:00:00Z","validTo":"2099-01-01T00:00:00Z","tariff":{
"unitRate":23.37,"standingCharge":46.63,"productCode":"OE-FIX-12M","tariffCode":"E-1R-OE-FIX-12M-J"
}}]}
],
"gasMeterPoints":[]
}]}]}}}`)
}))
defer srv.Close()
octopusGraphQL = srv.URL + "/"
rates, err := getRates("token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rates.ElectricityUnitRate != 23.37 {
t.Errorf("import unit rate: got %v, want 23.37", rates.ElectricityUnitRate)
}
if !rates.SolarHasExport {
t.Error("expected SolarHasExport=true")
}
if rates.SolarExportRate != 12.0 {
t.Errorf("export rate: got %v, want 12.0", rates.SolarExportRate)
}
}
func TestGetRates_WithGas(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{

View file

@ -10,13 +10,14 @@ type telemetryReading struct {
ReadAt string `json:"readAt"`
Consumption jsonFloat `json:"consumption"`
Demand jsonFloat `json:"demand"`
Export jsonFloat `json:"export"`
}
func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
result, err := doGraphQL(gqlRequest{
OperationName: "getSmartMeterTelemetry",
Variables: map[string]any{"meterDeviceId": deviceID},
Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n __typename\n }\n}\n",
Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n export\n __typename\n }\n}\n",
}, token)
if err != nil {
return nil, err