346 lines
11 KiB
Go
346 lines
11 KiB
Go
package tariff
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/evcc-io/evcc/api"
|
|
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
|
|
"github.com/evcc-io/evcc/util"
|
|
)
|
|
|
|
type OctopusDe struct {
|
|
log *util.Logger
|
|
gqlClient *octoDeGql.OctopusDeGraphQLClient
|
|
data *util.Monitor[api.Rates]
|
|
}
|
|
|
|
var _ api.Tariff = (*OctopusDe)(nil)
|
|
|
|
func init() {
|
|
registry.Add("octopus-de", NewOctopusDeFromConfig)
|
|
}
|
|
|
|
// NewOctopusDeFromConfig creates the tariff provider from the given config map, and runs it.
|
|
func NewOctopusDeFromConfig(other map[string]any) (api.Tariff, error) {
|
|
t, err := buildOctopusDeFromConfig(other)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return runOrError(t)
|
|
}
|
|
|
|
// buildOctopusDeFromConfig creates the Tariff provider from the given config map.
|
|
// Split out to allow for testing.
|
|
func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) {
|
|
var cc struct {
|
|
Email string
|
|
Password string
|
|
AccountNumber string
|
|
}
|
|
|
|
if err := util.DecodeOther(other, &cc); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if cc.Email == "" {
|
|
return nil, errors.New("missing email")
|
|
}
|
|
|
|
if cc.Password == "" {
|
|
return nil, errors.New("missing password")
|
|
}
|
|
|
|
if cc.AccountNumber == "" {
|
|
return nil, errors.New("missing account number")
|
|
}
|
|
|
|
log := util.NewLogger("octopus-de")
|
|
|
|
// Create GraphQL client
|
|
gqlClient, err := octoDeGql.NewClient(log, cc.Email, cc.Password, cc.AccountNumber)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
t := &OctopusDe{
|
|
log: log,
|
|
gqlClient: gqlClient,
|
|
data: util.NewMonitor[api.Rates](2 * time.Hour),
|
|
}
|
|
|
|
return t, nil
|
|
}
|
|
|
|
func (t *OctopusDe) run(done chan error) {
|
|
var once sync.Once
|
|
|
|
for tick := time.Tick(time.Hour); ; <-tick {
|
|
var rates []RatePeriod
|
|
|
|
if err := backoff.Retry(func() error {
|
|
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 })
|
|
|
|
t.log.ERROR.Printf("failed to fetch unit rate forecast: %v", err)
|
|
continue
|
|
}
|
|
|
|
data := make(api.Rates, 0, len(rates))
|
|
for _, r := range rates {
|
|
// ValidTo can be zero which means the rate has no expected end
|
|
// Set it to a date far in the future in this case
|
|
rateEnd := r.ValidTo
|
|
if rateEnd.IsZero() {
|
|
t.log.TRACE.Printf("handling rate with indefinite length: %v", r.ValidFrom)
|
|
// Add a year from the start date
|
|
rateEnd = r.ValidFrom.AddDate(1, 0, 0)
|
|
}
|
|
ar := api.Rate{
|
|
Start: r.ValidFrom,
|
|
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.GrossUnitRateCentsPerKwh / 100,
|
|
}
|
|
data = append(data, ar)
|
|
}
|
|
|
|
mergeRates(t.data, data)
|
|
once.Do(func() { close(done) })
|
|
}
|
|
}
|
|
|
|
// Rates implements the api.Tariff interface
|
|
func (t *OctopusDe) Rates() (api.Rates, error) {
|
|
var res api.Rates
|
|
err := t.data.GetFunc(func(val api.Rates) {
|
|
res = slices.Clone(val)
|
|
})
|
|
return res, err
|
|
}
|
|
|
|
// Type implements the api.Tariff interface
|
|
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)
|
|
}
|