Octopus DE: support time-of-use and simple tariffs (#28001)

This commit is contained in:
Johannes Rudolph 2026-03-09 18:56:12 +01:00 • committed by GitHub
parent 16812adcb2
commit 6c9adf9003
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 453 additions and 88 deletions

View file

@ -2,7 +2,9 @@ package tariff
import (
"errors"
"fmt"
"slices"
"strconv"
"sync"
"time"
@ -80,11 +82,14 @@ func (t *OctopusDe) run(done chan error) {
var once sync.Once
for tick := time.Tick(time.Hour); ; <-tick {
var rates []octoDeGql.RatePeriod
var rates []RatePeriod
if err := backoff.Retry(func() error {
var err error
rates, err = t.gqlClient.UnitRateForecast()
agr, err := t.gqlClient.ActiveAgreement()
if err != nil {
return backoffPermanentError(err)
}
rates, err = ratesForAgreement(agr, time.Now())
return backoffPermanentError(err)
}, bo()); err != nil {
once.Do(func() { done <- err })
@ -108,7 +113,7 @@ func (t *OctopusDe) run(done chan error) {
End: rateEnd,
// Convert from cents per kWh to price per kWh (divide by 100)
// Use gross price (including tax) as that's what the customer pays
Value: r.LatestGrossUnitRateCentsPerKwh / 100,
Value: r.GrossUnitRateCentsPerKwh / 100,
}
data = append(data, ar)
}
@ -131,3 +136,211 @@ func (t *OctopusDe) Rates() (api.Rates, error) {
func (t *OctopusDe) Type() api.TariffType {
return api.TariffTypePriceForecast
}
// RatePeriod represents a parsed rate period with pricing in cents per kWh.
type RatePeriod struct {
ValidFrom time.Time
ValidTo time.Time
NetUnitRateCentsPerKwh float64
GrossUnitRateCentsPerKwh float64
}
// ratesForAgreement determines the tariff type of agr and returns the corresponding
// rate periods. It supports Dynamic, Simple, and Time-of-Use tariffs.
// now is used as the reference time for ToU rate generation.
func ratesForAgreement(agr octoDeGql.Agreement, now time.Time) ([]RatePeriod, error) {
// Dynamic tariff: has unitRateForecast entries with per-slot prices
if len(agr.UnitRateForecast) > 0 {
rates, err := extractForecastRates(agr.UnitRateForecast)
if err != nil {
return nil, err
}
if len(rates) > 0 {
return rates, nil
}
}
// Simple tariff: single fixed rate covering the agreement period
if agr.UnitRateInformation.SimpleProductUnitRateInformation.LatestGrossUnitRateCentsPerKwh != "" {
return simpleRates(agr.UnitRateInformation.SimpleProductUnitRateInformation, agr.ValidFrom, agr.ValidTo)
}
// Time of Use tariff: multiple time-slot rates that repeat daily
if touRateSlots := agr.UnitRateInformation.TimeOfUseProductUnitRateInformation.Rates; len(touRateSlots) > 0 {
return generateTouRates(touRateSlots, agr.ValidTo, now)
}
return nil, errors.New("unsupported tariff type for active agreement")
}
// extractForecastRates converts dynamic-tariff UnitRateForecast entries into RatePeriod values.
func extractForecastRates(forecasts []octoDeGql.UnitRateForecast) ([]RatePeriod, error) {
var rates []RatePeriod
for _, forecast := range forecasts {
info := forecast.UnitRateInformation
// Dynamic forecasts typically use TimeOfUseProductUnitRateInformation
if info.TimeOfUseProductUnitRateInformation.Rates != nil {
for _, r := range info.TimeOfUseProductUnitRateInformation.Rates {
netRate, err := parseFloat(r.NetUnitRateCentsPerKwh)
if err != nil {
return nil, fmt.Errorf("failed to parse net unit rate: %w", err)
}
grossRate, err := parseFloat(r.LatestGrossUnitRateCentsPerKwh)
if err != nil {
return nil, fmt.Errorf("failed to parse gross unit rate: %w", err)
}
rates = append(rates, RatePeriod{
ValidFrom: forecast.ValidFrom,
ValidTo: forecast.ValidTo,
GrossUnitRateCentsPerKwh: grossRate,
NetUnitRateCentsPerKwh: netRate,
})
}
continue
}
// Forecast that uses SimpleProductUnitRateInformation
if info.SimpleProductUnitRateInformation.LatestGrossUnitRateCentsPerKwh != "" {
r, err := simpleRates(info.SimpleProductUnitRateInformation, forecast.ValidFrom, forecast.ValidTo)
if err != nil {
return nil, err
}
rates = append(rates, r...)
}
}
return rates, nil
}
// simpleRates converts a SimpleProductUnitRateInformation into a single RatePeriod
// covering from to to. A zero to means indefinite; run() handles zero ValidTo.
func simpleRates(info octoDeGql.SimpleProductUnitRateInformation, from, to time.Time) ([]RatePeriod, error) {
netRate, err := parseFloat(info.NetUnitRateCentsPerKwh)
if err != nil {
return nil, fmt.Errorf("failed to parse net unit rate: %w", err)
}
grossRate, err := parseFloat(info.LatestGrossUnitRateCentsPerKwh)
if err != nil {
return nil, fmt.Errorf("failed to parse gross unit rate: %w", err)
}
if from.IsZero() {
from = time.Now()
}
return []RatePeriod{{
ValidFrom: from,
ValidTo: to, // zero means indefinite; run() handles zero ValidTo
GrossUnitRateCentsPerKwh: grossRate,
NetUnitRateCentsPerKwh: netRate,
}}, nil
}
// computeHorizon returns the end of the planning window, capped by agreementValidTo.
func computeHorizon(now, agreementValidTo time.Time, planDays int) time.Time {
h := now.AddDate(0, 0, planDays)
if !agreementValidTo.IsZero() && agreementValidTo.Before(h) {
return agreementValidTo
}
return h
}
// computePeriod converts day-relative time offsets into absolute start/end times,
// handling the midnight-wrapping convention ("00:00:00" means end-of-day).
func computePeriod(day time.Time, fromOffset, toOffset time.Duration) (time.Time, time.Time) {
start := day.Add(fromOffset)
var end time.Time
switch {
case toOffset == 0:
// "00:00:00" as end means end of day (midnight)
end = day.Add(24 * time.Hour)
case toOffset < fromOffset:
// wraps past midnight
end = day.Add(toOffset).Add(24 * time.Hour)
default:
end = day.Add(toOffset)
}
return start, end
}
// ratePeriodsForDay expands one TouRate slot for a single day into RatePeriods,
// filtered to the window [now, horizon].
func ratePeriodsForDay(day, now, horizon time.Time, r octoDeGql.TouRate) ([]RatePeriod, error) {
grossRate, err := parseFloat(r.LatestGrossUnitRateCentsPerKwh)
if err != nil {
return nil, fmt.Errorf("failed to parse gross unit rate for slot %q: %w", r.TimeslotName, err)
}
netRate, err := parseFloat(r.NetUnitRateCentsPerKwh)
if err != nil {
return nil, fmt.Errorf("failed to parse net unit rate for slot %q: %w", r.TimeslotName, err)
}
var periods []RatePeriod
for _, rule := range r.TimeslotActivationRules {
fromOffset, err := parseTimeOfDay(rule.ActiveFromTime)
if err != nil {
return nil, fmt.Errorf("failed to parse activeFromTime %q: %w", rule.ActiveFromTime, err)
}
toOffset, err := parseTimeOfDay(rule.ActiveToTime)
if err != nil {
return nil, fmt.Errorf("failed to parse activeToTime %q: %w", rule.ActiveToTime, err)
}
start, end := computePeriod(day, fromOffset, toOffset)
if end.Before(now) || start.After(horizon) {
continue
}
periods = append(periods, RatePeriod{
ValidFrom: start,
ValidTo: end,
GrossUnitRateCentsPerKwh: grossRate,
NetUnitRateCentsPerKwh: netRate,
})
}
return periods, nil
}
// generateTouRates produces rate periods for a Time of Use tariff over the next 7 days
// by repeating each timeslot's activation window for each day in the planning horizon.
// now is the reference time used for filtering past periods and computing the horizon.
func generateTouRates(rates []octoDeGql.TouRate, agreementValidTo time.Time, now time.Time) ([]RatePeriod, error) {
const planDays = 7
horizon := computeHorizon(now, agreementValidTo, planDays)
startDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var result []RatePeriod
for day := startDay; day.Before(horizon); day = day.Add(24 * time.Hour) {
for _, r := range rates {
dayPeriods, err := ratePeriodsForDay(day, now, horizon, r)
if err != nil {
return nil, err
}
result = append(result, dayPeriods...)
}
}
if len(result) == 0 {
if !agreementValidTo.IsZero() && agreementValidTo.Before(now) {
return nil, errors.New("time-of-use agreement has expired")
}
return nil, errors.New("time-of-use tariff has no upcoming periods")
}
return result, nil
}
// parseTimeOfDay parses a time string in "HH:MM:SS" or "HH:MM" format and returns
// the duration offset from midnight.
func parseTimeOfDay(s string) (time.Duration, error) {
for _, layout := range []string{"15:04:05", "15:04"} {
if t, err := time.Parse(layout, s); err == nil {
return time.Duration(t.Hour())*time.Hour +
time.Duration(t.Minute())*time.Minute +
time.Duration(t.Second())*time.Second, nil
}
}
return 0, fmt.Errorf("unsupported time format %q", s)
}
// parseFloat parses a string to float64.
func parseFloat(s string) (float64, error) {
return strconv.ParseFloat(s, 64)
}

View file

@ -3,9 +3,7 @@ package graphql
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/evcc-io/evcc/util"
@ -57,81 +55,40 @@ func NewClient(log *util.Logger, email, password, accountNumber string) (*Octopu
return gq, nil
}
// UnitRateForecast queries the day-ahead price forecast for the account
func (c *OctopusDeGraphQLClient) UnitRateForecast() ([]RatePeriod, error) {
// ActiveAgreement queries the Kraken API and returns the active electricity supply agreement.
func (c *OctopusDeGraphQLClient) ActiveAgreement() (Agreement, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
var q getDayAheadPrices
var q getAgreements
if err := c.Client.Query(ctx, &q, map[string]any{
"accountNumber": c.accountNumber,
}); err != nil {
return nil, err
return Agreement{}, err
}
// Extract rates from the query result
if len(q.Account.Properties) == 0 {
return nil, errors.New("no properties found")
return Agreement{}, errors.New("no properties found")
}
// Find the active agreement across all properties
var unitRateForecast []unitRateForecast
agr, err := findActiveAgreement(&q)
if err != nil {
return Agreement{}, err
}
return *agr, nil
}
// findActiveAgreement returns the first agreement marked IsActive across all properties.
func findActiveAgreement(q *getAgreements) (*Agreement, error) {
for _, property := range q.Account.Properties {
for _, malo := range property.ElectricityMalos {
for _, agreement := range malo.Agreements {
if agreement.IsActive {
unitRateForecast = agreement.UnitRateForecast
break
for _, agr := range malo.Agreements {
if agr.IsActive {
return &agr, nil
}
}
if unitRateForecast != nil {
break
}
}
if unitRateForecast != nil {
break
}
}
if unitRateForecast == nil {
return nil, errors.New("no active agreement found")
}
// Convert to RatePeriod slice
var rates []RatePeriod
for _, forecast := range unitRateForecast {
// Extract the rate from the union type
if forecast.UnitRateInformation.TimeOfUseProductUnitRateInformation.Rates != nil {
for _, rate := range forecast.UnitRateInformation.TimeOfUseProductUnitRateInformation.Rates {
// Parse string values to float64
netRate, err := parseFloat(rate.NetUnitRateCentsPerKwh)
if err != nil {
return nil, fmt.Errorf("failed to parse net unit rate: %w", err)
}
grossRate, err := parseFloat(rate.LatestGrossUnitRateCentsPerKwh)
if err != nil {
return nil, fmt.Errorf("failed to parse gross unit rate: %w", err)
}
rates = append(rates, RatePeriod{
ValidFrom: forecast.ValidFrom,
ValidTo: forecast.ValidTo,
LatestGrossUnitRateCentsPerKwh: grossRate,
NetUnitRateCentsPerKwh: netRate,
})
}
}
}
if len(rates) == 0 {
return nil, errors.New("no rate forecast available")
}
return rates, nil
}
// parseFloat parses a string to float64, handling the specific format used by Octopus API
func parseFloat(s string) (float64, error) {
return strconv.ParseFloat(s, 64)
return nil, errors.New("no active agreement found")
}

View file

@ -9,16 +9,23 @@ type krakenTokenAuthentication struct {
} `graphql:"obtainKrakenToken(input: {email: $email, password: $password})"`
}
// getDayAheadPrices queries the day-ahead price forecast
type getDayAheadPrices struct {
// Agreement represents a single electricity supply agreement.
// The unitRateForecast field is only populated for dynamic tariffs.
// The unitRateInformation field covers all tariff types (Simple, TimeOfUse).
type Agreement struct {
IsActive bool
ValidFrom time.Time
ValidTo time.Time
UnitRateInformation AgreementUnitRateInformation
UnitRateForecast []UnitRateForecast
Product product
}
type getAgreements struct {
Account struct {
Properties []struct {
ElectricityMalos []struct {
Agreements []struct {
IsActive bool
UnitRateForecast []unitRateForecast
Product product
}
Agreements []Agreement
}
}
} `graphql:"account(accountNumber: $accountNumber)"`
@ -30,29 +37,61 @@ type product struct {
Term int
}
type unitRateForecast struct {
// AgreementUnitRateInformation is the current rate information for an agreement.
// It supports both SimpleProductUnitRateInformation (fixed rate) and
// TimeOfUseProductUnitRateInformation (time-slot based rates with activation rules).
type AgreementUnitRateInformation struct {
SimpleProductUnitRateInformation SimpleProductUnitRateInformation `graphql:"... on SimpleProductUnitRateInformation"`
TimeOfUseProductUnitRateInformation TouAgreementUnitRateInformation `graphql:"... on TimeOfUseProductUnitRateInformation"`
}
// SimpleProductUnitRateInformation holds a single fixed rate.
type SimpleProductUnitRateInformation struct {
LatestGrossUnitRateCentsPerKwh string
NetUnitRateCentsPerKwh string
}
// TouAgreementUnitRateInformation holds multiple time-slot rates with their activation rules.
type TouAgreementUnitRateInformation struct {
Rates []TouRate
}
// TouRate is a rate with time-slot activation rules (used in non-dynamic ToU agreements).
type TouRate struct {
NetUnitRateCentsPerKwh string `graphql:"netUnitRateCentsPerKwh"`
LatestGrossUnitRateCentsPerKwh string `graphql:"latestGrossUnitRateCentsPerKwh"`
TimeslotName string
TimeslotActivationRules []TimeslotActivationRule
}
// TimeslotActivationRule defines the time window during which a rate slot is active.
type TimeslotActivationRule struct {
ActiveFromTime string
ActiveToTime string
}
// UnitRateForecast holds a single forecast entry with its validity window.
type UnitRateForecast struct {
ValidFrom time.Time
ValidTo time.Time
UnitRateInformation unitRateInformation
UnitRateInformation ForecastUnitRateInformation
}
type unitRateInformation struct {
TimeOfUseProductUnitRateInformation timeOfUseProductUnitRateInformation `graphql:"... on TimeOfUseProductUnitRateInformation"`
// ForecastUnitRateInformation is the rate information embedded in forecast entries.
// Dynamic tariffs use TimeOfUseProductUnitRateInformation; simple forecasts use
// SimpleProductUnitRateInformation.
type ForecastUnitRateInformation struct {
SimpleProductUnitRateInformation SimpleProductUnitRateInformation `graphql:"... on SimpleProductUnitRateInformation"`
TimeOfUseProductUnitRateInformation TimeOfUseProductUnitRateInformation `graphql:"... on TimeOfUseProductUnitRateInformation"`
}
type timeOfUseProductUnitRateInformation struct {
Rates []rate
// TimeOfUseProductUnitRateInformation holds a list of per-slot rates for dynamic/ToU forecasts.
type TimeOfUseProductUnitRateInformation struct {
Rates []Rate
}
type rate struct {
// Rate holds the net and gross unit rate strings for a single dynamic forecast slot.
type Rate struct {
NetUnitRateCentsPerKwh string `graphql:"netUnitRateCentsPerKwh"`
LatestGrossUnitRateCentsPerKwh string `graphql:"latestGrossUnitRateCentsPerKwh"`
}
// RatePeriod represents a rate period with pricing information
type RatePeriod struct {
ValidFrom time.Time
ValidTo time.Time
NetUnitRateCentsPerKwh float64
LatestGrossUnitRateCentsPerKwh float64
}

View file

@ -2,7 +2,10 @@ package tariff
import (
"testing"
"time"
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -41,3 +44,156 @@ func TestOctopusDeConfigParse(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "missing account number")
}
// t0 is a fixed reference time (Monday midnight UTC) used across
// forecast tests so that time-of-use period generation is fully deterministic.
var t0 = time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
// dynamicAgreement builds an agreement that uses a dynamic tariff:
// the unitRateForecast field contains two half-hour forecast slots with
// per-slot prices stored in TimeOfUseProductUnitRateInformation.
func dynamicAgreement() octoDeGql.Agreement {
t1 := t0
t2 := t0.Add(15 * time.Minute)
t3 := t2.Add(15 * time.Minute)
return octoDeGql.Agreement{
IsActive: true,
UnitRateForecast: []octoDeGql.UnitRateForecast{
{
ValidFrom: t1,
ValidTo: t2,
UnitRateInformation: octoDeGql.ForecastUnitRateInformation{
TimeOfUseProductUnitRateInformation: octoDeGql.TimeOfUseProductUnitRateInformation{
Rates: []octoDeGql.Rate{
{NetUnitRateCentsPerKwh: "10.50", LatestGrossUnitRateCentsPerKwh: "12.495"},
},
},
},
},
{
ValidFrom: t2,
ValidTo: t3,
UnitRateInformation: octoDeGql.ForecastUnitRateInformation{
TimeOfUseProductUnitRateInformation: octoDeGql.TimeOfUseProductUnitRateInformation{
Rates: []octoDeGql.Rate{
{NetUnitRateCentsPerKwh: "8.00", LatestGrossUnitRateCentsPerKwh: "9.52"},
},
},
},
},
},
}
}
// simpleAgreement builds an agreement with a single fixed rate covering one year.
func simpleAgreement() octoDeGql.Agreement {
return octoDeGql.Agreement{
IsActive: true,
ValidFrom: t0,
ValidTo: t0.AddDate(1, 0, 0),
UnitRateInformation: octoDeGql.AgreementUnitRateInformation{
SimpleProductUnitRateInformation: octoDeGql.SimpleProductUnitRateInformation{
NetUnitRateCentsPerKwh: "25.00",
LatestGrossUnitRateCentsPerKwh: "29.75",
},
},
}
}
// touAgreement builds an agreement with a two-slot time-of-use tariff:
// - Day rate 06:00–22:00
// - Night rate 22:00–06:00 (wraps past midnight)
func touAgreement() octoDeGql.Agreement {
return octoDeGql.Agreement{
IsActive: true,
UnitRateInformation: octoDeGql.AgreementUnitRateInformation{
TimeOfUseProductUnitRateInformation: octoDeGql.TouAgreementUnitRateInformation{
Rates: []octoDeGql.TouRate{
{
TimeslotName: "Day",
NetUnitRateCentsPerKwh: "30.00",
LatestGrossUnitRateCentsPerKwh: "35.70",
TimeslotActivationRules: []octoDeGql.TimeslotActivationRule{
{ActiveFromTime: "06:00:00", ActiveToTime: "22:00:00"},
},
},
{
TimeslotName: "Night",
NetUnitRateCentsPerKwh: "15.00",
LatestGrossUnitRateCentsPerKwh: "17.85",
TimeslotActivationRules: []octoDeGql.TimeslotActivationRule{
// 22:00 → 06:00 wraps past midnight
{ActiveFromTime: "22:00:00", ActiveToTime: "06:00:00"},
},
},
},
},
},
}
}
// TestRatesForAgreement_Dynamic verifies that a dynamic tariff agreement returns
// one RatePeriod per forecast entry, preserving ValidFrom/ValidTo and rates.
func TestRatesForAgreement_Dynamic(t *testing.T) {
rates, err := ratesForAgreement(dynamicAgreement(), t0)
require.NoError(t, err)
require.Len(t, rates, 2)
assert.Equal(t, t0, rates[0].ValidFrom)
assert.Equal(t, t0.Add(15*time.Minute), rates[0].ValidTo)
assert.InDelta(t, 10.50, rates[0].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 12.495, rates[0].GrossUnitRateCentsPerKwh, 0.001)
assert.Equal(t, t0.Add(15*time.Minute), rates[1].ValidFrom)
assert.Equal(t, t0.Add(30*time.Minute), rates[1].ValidTo)
assert.InDelta(t, 8.00, rates[1].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 9.52, rates[1].GrossUnitRateCentsPerKwh, 0.001)
}
// TestRatesForAgreement_Simple verifies that a simple fixed-rate agreement returns
// a single RatePeriod spanning the full agreement validity window.
func TestRatesForAgreement_Simple(t *testing.T) {
rates, err := ratesForAgreement(simpleAgreement(), t0)
require.NoError(t, err)
require.Len(t, rates, 1)
assert.Equal(t, t0, rates[0].ValidFrom)
assert.Equal(t, t0.AddDate(1, 0, 0), rates[0].ValidTo)
assert.InDelta(t, 25.00, rates[0].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 29.75, rates[0].GrossUnitRateCentsPerKwh, 0.001)
}
// TestRatesForAgreement_TimeOfUse verifies that a two-slot ToU tariff is expanded
// into 14 RatePeriods (7 days × 2 slots). With testNow at midnight the entire
// first day is in the future, so no period is filtered out.
//
// Expected layout per day (repeated 7 times):
//
// rates[2n+0]: Day [day+06:00, day+22:00] net=30 gross=35.70
// rates[2n+1]: Night [day+22:00, day+30:00] net=15 gross=17.85 (wraps to next-day 06:00)
func TestRatesForAgreement_TimeOfUse(t *testing.T) {
rates, err := ratesForAgreement(touAgreement(), t0)
require.NoError(t, err)
require.Len(t, rates, 14)
// --- Day-0 day slot ---
assert.Equal(t, t0.Add(6*time.Hour), rates[0].ValidFrom)
assert.Equal(t, t0.Add(22*time.Hour), rates[0].ValidTo)
assert.InDelta(t, 30.00, rates[0].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 35.70, rates[0].GrossUnitRateCentsPerKwh, 0.001)
// --- Day-0 night slot (wraps: 22:00 → next-day 06:00 = +30h) ---
assert.Equal(t, t0.Add(22*time.Hour), rates[1].ValidFrom)
assert.Equal(t, t0.Add(30*time.Hour), rates[1].ValidTo)
assert.InDelta(t, 15.00, rates[1].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 17.85, rates[1].GrossUnitRateCentsPerKwh, 0.001)
// --- Day-6 day slot (last day within 7-day horizon) ---
day6 := t0.Add(6 * 24 * time.Hour)
assert.Equal(t, day6.Add(6*time.Hour), rates[12].ValidFrom)
assert.Equal(t, day6.Add(22*time.Hour), rates[12].ValidTo)
// --- Day-6 night slot (starts before the 7-day horizon, so included) ---
assert.Equal(t, day6.Add(22*time.Hour), rates[13].ValidFrom)
assert.Equal(t, day6.Add(30*time.Hour), rates[13].ValidTo)
}