evcc-io/tariff/octopusde_test.go
2026-03-19 13:28:36 +01:00

259 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tariff
import (
"testing"
"time"
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOctopusDeConfigParse(t *testing.T) {
validConfig := map[string]any{
"email": "test@example.com",
"password": "testpassword",
"accountNumber": "A-12345678",
}
tariff, err := buildOctopusDeFromConfig(validConfig)
require.NoError(t, err)
require.NotNil(t, tariff)
missingEmailConfig := map[string]any{
"password": "testpassword",
"accountNumber": "A-12345678",
}
_, err = buildOctopusDeFromConfig(missingEmailConfig)
require.Error(t, err)
require.Contains(t, err.Error(), "missing email")
missingPasswordConfig := map[string]any{
"email": "test@example.com",
"accountNumber": "A-12345678",
}
_, err = buildOctopusDeFromConfig(missingPasswordConfig)
require.Error(t, err)
require.Contains(t, err.Error(), "missing password")
missingAccountNumberConfig := map[string]any{
"email": "test@example.com",
"password": "testpassword",
}
_, err = buildOctopusDeFromConfig(missingAccountNumberConfig)
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 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(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.
//
// 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)
}
// 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)
}