diff --git a/tariff/octopusde.go b/tariff/octopusde.go index 9e5f6d5fb..0f56822cc 100644 --- a/tariff/octopusde.go +++ b/tariff/octopusde.go @@ -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") diff --git a/tariff/octopusde_test.go b/tariff/octopusde_test.go index 6a06220ca..598f48c60 100644 --- a/tariff/octopusde_test.go +++ b/tariff/octopusde_test.go @@ -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) +}