Octopus DE: support time-of-use and simple tariffs (#28001)
This commit is contained in:
parent
16812adcb2
commit
6c9adf9003
4 changed files with 453 additions and 88 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue