259 lines
9.3 KiB
Go
259 lines
9.3 KiB
Go
package tariff
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
krakengql "github.com/evcc-io/evcc/tariff/octopuskraken/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() krakengql.Agreement {
|
||
t1 := t0
|
||
t2 := t0.Add(15 * time.Minute)
|
||
t3 := t2.Add(15 * time.Minute)
|
||
return krakengql.Agreement{
|
||
IsActive: true,
|
||
UnitRateForecast: []krakengql.UnitRateForecast{
|
||
{
|
||
ValidFrom: t1,
|
||
ValidTo: t2,
|
||
UnitRateInformation: krakengql.ForecastUnitRateInformation{
|
||
TimeOfUseProductUnitRateInformation: krakengql.TimeOfUseProductUnitRateInformation{
|
||
Rates: []krakengql.Rate{
|
||
{NetUnitRateCentsPerKwh: "10.50", LatestGrossUnitRateCentsPerKwh: "12.495"},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
ValidFrom: t2,
|
||
ValidTo: t3,
|
||
UnitRateInformation: krakengql.ForecastUnitRateInformation{
|
||
TimeOfUseProductUnitRateInformation: krakengql.TimeOfUseProductUnitRateInformation{
|
||
Rates: []krakengql.Rate{
|
||
{NetUnitRateCentsPerKwh: "8.00", LatestGrossUnitRateCentsPerKwh: "9.52"},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// simpleAgreement builds an agreement with a single fixed rate covering one year.
|
||
func simpleAgreement() krakengql.Agreement {
|
||
return krakengql.Agreement{
|
||
IsActive: true,
|
||
ValidFrom: t0,
|
||
ValidTo: t0.AddDate(1, 0, 0),
|
||
UnitRateInformation: krakengql.AgreementUnitRateInformation{
|
||
SimpleProductUnitRateInformation: krakengql.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() krakengql.Agreement {
|
||
return krakengql.Agreement{
|
||
IsActive: true,
|
||
UnitRateInformation: krakengql.AgreementUnitRateInformation{
|
||
TimeOfUseProductUnitRateInformation: krakengql.TouAgreementUnitRateInformation{
|
||
Rates: []krakengql.TouRate{
|
||
{
|
||
TimeslotName: "Day",
|
||
NetUnitRateCentsPerKwh: "30.00",
|
||
LatestGrossUnitRateCentsPerKwh: "35.70",
|
||
TimeslotActivationRules: []krakengql.TimeslotActivationRule{
|
||
{ActiveFromTime: "06:00:00", ActiveToTime: "22:00:00"},
|
||
},
|
||
},
|
||
{
|
||
TimeslotName: "Night",
|
||
NetUnitRateCentsPerKwh: "15.00",
|
||
LatestGrossUnitRateCentsPerKwh: "17.85",
|
||
TimeslotActivationRules: []krakengql.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)
|
||
}
|