From 6c9adf90036606e65868ba3ca28c6bd593b50cc0 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Mon, 9 Mar 2026 18:56:12 +0100 Subject: [PATCH] Octopus DE: support time-of-use and simple tariffs (#28001) --- tariff/octopusde.go | 221 +++++++++++++++++++++++++++++- tariff/octopusde/graphql/api.go | 81 +++-------- tariff/octopusde/graphql/types.go | 83 ++++++++--- tariff/octopusde_test.go | 156 +++++++++++++++++++++ 4 files changed, 453 insertions(+), 88 deletions(-) diff --git a/tariff/octopusde.go b/tariff/octopusde.go index 286ac8bf3..9e5f6d5fb 100644 --- a/tariff/octopusde.go +++ b/tariff/octopusde.go @@ -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) +} diff --git a/tariff/octopusde/graphql/api.go b/tariff/octopusde/graphql/api.go index 97d080b95..3b3a7d964 100644 --- a/tariff/octopusde/graphql/api.go +++ b/tariff/octopusde/graphql/api.go @@ -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") } diff --git a/tariff/octopusde/graphql/types.go b/tariff/octopusde/graphql/types.go index c5e4d4dbd..e5c8cd153 100644 --- a/tariff/octopusde/graphql/types.go +++ b/tariff/octopusde/graphql/types.go @@ -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 -} diff --git a/tariff/octopusde_test.go b/tariff/octopusde_test.go index 18d4ae66e..6a06220ca 100644 --- a/tariff/octopusde_test.go +++ b/tariff/octopusde_test.go @@ -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) +}