Octopus: fix tariff rates to planning window (#28313)
This commit is contained in:
parent
c96769981c
commit
708b1c88e2
2 changed files with 118 additions and 42 deletions
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/evcc-io/evcc/api"
|
||||
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/jinzhu/now"
|
||||
)
|
||||
|
||||
type OctopusDe struct {
|
||||
|
|
@ -20,6 +21,11 @@ type OctopusDe struct {
|
|||
data *util.Monitor[api.Rates]
|
||||
}
|
||||
|
||||
type planningHorizon struct {
|
||||
start time.Time
|
||||
end time.Time
|
||||
}
|
||||
|
||||
var _ api.Tariff = (*OctopusDe)(nil)
|
||||
|
||||
func init() {
|
||||
|
|
@ -100,18 +106,10 @@ func (t *OctopusDe) run(done chan error) {
|
|||
|
||||
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)
|
||||
End: r.ValidTo,
|
||||
// Convert from cents per kWh to € per kWh (divide by 100)
|
||||
// Use gross price (including tax) as that's what the customer pays
|
||||
Value: r.GrossUnitRateCentsPerKwh / 100,
|
||||
}
|
||||
|
|
@ -137,6 +135,9 @@ func (t *OctopusDe) Type() api.TariffType {
|
|||
return api.TariffTypePriceForecast
|
||||
}
|
||||
|
||||
// planDays is the planning horizon used for all tariff types.
|
||||
const planDays = 7
|
||||
|
||||
// RatePeriod represents a parsed rate period with pricing in cents per kWh.
|
||||
type RatePeriod struct {
|
||||
ValidFrom time.Time
|
||||
|
|
@ -147,11 +148,16 @@ type RatePeriod struct {
|
|||
|
||||
// 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.
|
||||
// now is used as the reference time for horizon computation and ToU rate generation.
|
||||
func ratesForAgreement(agr octoDeGql.Agreement, now time.Time) ([]RatePeriod, error) {
|
||||
horizon, err := computeHorizon(now, agr, planDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Dynamic tariff: has unitRateForecast entries with per-slot prices
|
||||
if len(agr.UnitRateForecast) > 0 {
|
||||
rates, err := extractForecastRates(agr.UnitRateForecast)
|
||||
rates, err := extractForecastRates(agr.UnitRateForecast, horizon)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -162,24 +168,25 @@ func ratesForAgreement(agr octoDeGql.Agreement, now time.Time) ([]RatePeriod, er
|
|||
|
||||
// Simple tariff: single fixed rate covering the agreement period
|
||||
if agr.UnitRateInformation.SimpleProductUnitRateInformation.LatestGrossUnitRateCentsPerKwh != "" {
|
||||
return simpleRates(agr.UnitRateInformation.SimpleProductUnitRateInformation, agr.ValidFrom, agr.ValidTo)
|
||||
return simpleRates(agr.UnitRateInformation.SimpleProductUnitRateInformation, horizon)
|
||||
}
|
||||
|
||||
// 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 generateTouRates(touRateSlots, horizon)
|
||||
}
|
||||
|
||||
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) {
|
||||
func extractForecastRates(forecasts []octoDeGql.UnitRateForecast, horizon planningHorizon) ([]RatePeriod, error) {
|
||||
var rates []RatePeriod
|
||||
for _, forecast := range forecasts {
|
||||
info := forecast.UnitRateInformation
|
||||
|
||||
// Dynamic forecasts typically use TimeOfUseProductUnitRateInformation
|
||||
// We do expect that octopus will always return us data that falls within the planning horizon here
|
||||
if info.TimeOfUseProductUnitRateInformation.Rates != nil {
|
||||
for _, r := range info.TimeOfUseProductUnitRateInformation.Rates {
|
||||
netRate, err := parseFloat(r.NetUnitRateCentsPerKwh)
|
||||
|
|
@ -202,7 +209,7 @@ func extractForecastRates(forecasts []octoDeGql.UnitRateForecast) ([]RatePeriod,
|
|||
|
||||
// Forecast that uses SimpleProductUnitRateInformation
|
||||
if info.SimpleProductUnitRateInformation.LatestGrossUnitRateCentsPerKwh != "" {
|
||||
r, err := simpleRates(info.SimpleProductUnitRateInformation, forecast.ValidFrom, forecast.ValidTo)
|
||||
r, err := simpleRates(info.SimpleProductUnitRateInformation, horizon)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -213,8 +220,8 @@ func extractForecastRates(forecasts []octoDeGql.UnitRateForecast) ([]RatePeriod,
|
|||
}
|
||||
|
||||
// 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) {
|
||||
// ending at horizon, the pre-computed planning horizon.
|
||||
func simpleRates(info octoDeGql.SimpleProductUnitRateInformation, horizon planningHorizon) ([]RatePeriod, error) {
|
||||
netRate, err := parseFloat(info.NetUnitRateCentsPerKwh)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse net unit rate: %w", err)
|
||||
|
|
@ -223,24 +230,35 @@ func simpleRates(info octoDeGql.SimpleProductUnitRateInformation, from, to time.
|
|||
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
|
||||
ValidFrom: horizon.start,
|
||||
ValidTo: horizon.end,
|
||||
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
|
||||
// computeHorizon returns the planning window, capped by the validity of the agreement.
|
||||
func computeHorizon(now time.Time, agreement octoDeGql.Agreement, planDays int) (planningHorizon, error) {
|
||||
start := now
|
||||
end := now.AddDate(0, 0, planDays)
|
||||
|
||||
// Validate agreement overlaps with planning horizon
|
||||
if agreement.ValidFrom.After(end) || (!agreement.ValidTo.IsZero() && agreement.ValidTo.Before(start)) {
|
||||
return planningHorizon{}, errors.New("agreement is not valid for the planning horizon")
|
||||
}
|
||||
return h
|
||||
|
||||
// Cap the horizon to agreement validity period
|
||||
if agreement.ValidFrom.After(start) {
|
||||
start = agreement.ValidFrom
|
||||
}
|
||||
|
||||
// validTo may be unset if the agreement has no defined end yet (ie. automatically renewed)
|
||||
if !agreement.ValidTo.IsZero() && agreement.ValidTo.Before(end) {
|
||||
end = agreement.ValidTo
|
||||
}
|
||||
|
||||
return planningHorizon{start: start, end: end}, nil
|
||||
}
|
||||
|
||||
// computePeriod converts day-relative time offsets into absolute start/end times,
|
||||
|
|
@ -263,7 +281,7 @@ func computePeriod(day time.Time, fromOffset, toOffset time.Duration) (time.Time
|
|||
|
||||
// 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) {
|
||||
func ratePeriodsForDay(day time.Time, horizon planningHorizon, 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)
|
||||
|
|
@ -285,7 +303,7 @@ func ratePeriodsForDay(day, now, horizon time.Time, r octoDeGql.TouRate) ([]Rate
|
|||
}
|
||||
|
||||
start, end := computePeriod(day, fromOffset, toOffset)
|
||||
if end.Before(now) || start.After(horizon) {
|
||||
if end.Before(horizon.start) || start.After(horizon.end) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -299,18 +317,16 @@ func ratePeriodsForDay(day, now, horizon time.Time, r octoDeGql.TouRate) ([]Rate
|
|||
return periods, nil
|
||||
}
|
||||
|
||||
// generateTouRates produces rate periods for a Time of Use tariff over the next 7 days
|
||||
// generateTouRates produces rate periods for a Time of Use tariff
|
||||
// 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())
|
||||
// now is the reference time for filtering past periods; horizon is the pre-computed end of the window.
|
||||
func generateTouRates(rates []octoDeGql.TouRate, horizon planningHorizon) ([]RatePeriod, error) {
|
||||
startDay := now.With(horizon.start).BeginningOfDay()
|
||||
|
||||
var result []RatePeriod
|
||||
for day := startDay; day.Before(horizon); day = day.Add(24 * time.Hour) {
|
||||
for day := startDay; day.Before(horizon.end); day = day.Add(24 * time.Hour) {
|
||||
for _, r := range rates {
|
||||
dayPeriods, err := ratePeriodsForDay(day, now, horizon, r)
|
||||
dayPeriods, err := ratePeriodsForDay(day, horizon, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -319,7 +335,7 @@ func generateTouRates(rates []octoDeGql.TouRate, agreementValidTo time.Time, now
|
|||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
if !agreementValidTo.IsZero() && agreementValidTo.Before(now) {
|
||||
if horizon.end.Before(horizon.start) {
|
||||
return nil, errors.New("time-of-use agreement has expired")
|
||||
}
|
||||
return nil, errors.New("time-of-use tariff has no upcoming periods")
|
||||
|
|
|
|||
|
|
@ -151,18 +151,50 @@ func TestRatesForAgreement_Dynamic(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestRatesForAgreement_Simple verifies that a simple fixed-rate agreement returns
|
||||
// a single RatePeriod spanning the full agreement validity window.
|
||||
// a single RatePeriod capped to the planning horizon (7 days), not the full
|
||||
// agreement validity window. This prevents the planner from expanding a multi-year
|
||||
// agreement into thousands of 15-minute intervals.
|
||||
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.Equal(t, t0.AddDate(0, 0, planDays), rates[0].ValidTo)
|
||||
assert.InDelta(t, 25.00, rates[0].NetUnitRateCentsPerKwh, 0.001)
|
||||
assert.InDelta(t, 29.75, rates[0].GrossUnitRateCentsPerKwh, 0.001)
|
||||
}
|
||||
|
||||
// TestSimpleRateIndefiniteEnd verifies that a simple tariff with no end date (ValidTo
|
||||
// is zero) is also capped to the planning horizon rather than being handled as
|
||||
// indefinite (which would have previously resulted in run() adding one year).
|
||||
func TestSimpleRateIndefiniteEndCappedToPlanningHorizon(t *testing.T) {
|
||||
agr := simpleAgreement()
|
||||
agr.ValidTo = time.Time{}
|
||||
|
||||
rates, err := ratesForAgreement(agr, t0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rates, 1)
|
||||
|
||||
// A zero ValidTo has no cap from the agreement; computeHorizon returns now+planDays.
|
||||
assert.Equal(t, t0.AddDate(0, 0, planDays), rates[0].ValidTo)
|
||||
}
|
||||
|
||||
// TestSimpleRateStartCappedToNow verifies that when now is after the agreement's
|
||||
// ValidFrom, the rate period begins at now rather than the (past) agreement start.
|
||||
func TestSimpleRateStartCappedToNow(t *testing.T) {
|
||||
now := t0.Add(48 * time.Hour) // 2 days after agreement start
|
||||
agr := simpleAgreement()
|
||||
|
||||
rates, err := ratesForAgreement(agr, now)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rates, 1)
|
||||
|
||||
// ValidFrom should be now, not the past agreement start t0.
|
||||
assert.Equal(t, now, rates[0].ValidFrom)
|
||||
assert.Equal(t, now.AddDate(0, 0, planDays), rates[0].ValidTo)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -197,3 +229,31 @@ func TestRatesForAgreement_TimeOfUse(t *testing.T) {
|
|||
assert.Equal(t, day6.Add(22*time.Hour), rates[13].ValidFrom)
|
||||
assert.Equal(t, day6.Add(30*time.Hour), rates[13].ValidTo)
|
||||
}
|
||||
|
||||
// TestRatesForAgreement_InvalidAgreement verifies that an agreement with ValidFrom
|
||||
// after the planning horizon end returns an error.
|
||||
func TestRatesForAgreement_InvalidAgreement_NotYetStarted(t *testing.T) {
|
||||
// Create an agreement that starts well into the future, beyond the 7-day planning horizon
|
||||
futureStart := t0.AddDate(0, 0, planDays+10)
|
||||
agr := simpleAgreement()
|
||||
agr.ValidFrom = futureStart
|
||||
|
||||
rates, err := ratesForAgreement(agr, t0)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "agreement is not valid for the planning horizon")
|
||||
assert.Nil(t, rates)
|
||||
}
|
||||
|
||||
func TestRatesForAgreement_InvalidAgreement_AlreadyCompleted(t *testing.T) {
|
||||
// Create an agreement that ended before the current time, beyond the 7-day planning horizon
|
||||
pastStart := t0.AddDate(0, 0, -10)
|
||||
pastEnd := t0.AddDate(0, 0, -3)
|
||||
agr := simpleAgreement()
|
||||
agr.ValidFrom = pastStart
|
||||
agr.ValidTo = pastEnd
|
||||
|
||||
rates, err := ratesForAgreement(agr, t0)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "agreement is not valid for the planning horizon")
|
||||
assert.Nil(t, rates)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue