From 017d77d0be8511a5baab38d14902991a87b44e36 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 12 Jul 2026 13:26:33 +0200 Subject: [PATCH] octopus-it: fix account query for Italy's Kraken schema (#31656) --- tariff/octopusit.go | 53 +++++++++++++++++-- tariff/octopusit_test.go | 57 +++++++++++++++++++++ tariff/octopuskraken/graphql/it_api.go | 33 ++++++++++++ tariff/octopuskraken/graphql/it_types.go | 49 ++++++++++++++++++ templates/definition/tariff/octopus-it.yaml | 1 + 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 tariff/octopuskraken/graphql/it_api.go create mode 100644 tariff/octopuskraken/graphql/it_types.go diff --git a/tariff/octopusit.go b/tariff/octopusit.go index 190dd74a6..691e072bb 100644 --- a/tariff/octopusit.go +++ b/tariff/octopusit.go @@ -2,6 +2,7 @@ package tariff import ( "errors" + "fmt" "slices" "sync" "time" @@ -13,7 +14,7 @@ import ( ) // OctopusIt is an api.Tariff implementation for Octopus Energy Italy, reusing -// the Germany implementation's Kraken GraphQL client and rate computation. +// Germany's Kraken auth/transport but its own rate query (schemas diverge). type OctopusIt struct { log *util.Logger gqlClient *krakengql.Client @@ -84,14 +85,14 @@ func (t *OctopusIt) run(done chan error) { var rates []RatePeriod if err := backoff.Retry(func() error { - agr, err := t.gqlClient.ActiveAgreement() + agr, err := t.gqlClient.ItActiveAgreement() if err != nil { if errors.Is(err, krakengql.ErrAuthFailed) { return backoff.Permanent(err) } return backoffPermanentError(err) } - rates, err = ratesForAgreement(agr, time.Now()) + rates, err = ratesForItAgreement(agr, time.Now()) return backoffPermanentError(err) }, bo()); err != nil { if reportError(&once, done, err) { @@ -132,3 +133,49 @@ func (t *OctopusIt) Rates() (api.Rates, error) { func (t *OctopusIt) Type() api.TariffType { return api.TariffTypePriceForecast } + +// ratesForItAgreement returns a flat rate covering the planning horizon. +// Only FIXED_SINGLE_RATE products are supported - F1/F2/F3 is not implemented. +func ratesForItAgreement(agr krakengql.ItAgreement, now time.Time) ([]RatePeriod, error) { + horizon, err := computeItHorizon(now, agr, planDays) + if err != nil { + return nil, err + } + + p := agr.Product.Prices + if p.ConsumptionChargeF2 != "" || p.ConsumptionChargeF3 != "" { + return nil, fmt.Errorf("unsupported time-of-use product %q: F2/F3 rates are not implemented", agr.Product.Code) + } + + rate, err := parseFloat(p.ConsumptionCharge) + if err != nil { + return nil, fmt.Errorf("failed to parse consumption charge: %w", err) + } + + // prices are € per kWh; RatePeriod stores cents per kWh like the DE tariff. + return []RatePeriod{{ + ValidFrom: horizon.start, + ValidTo: horizon.end, + GrossUnitRateCentsPerKwh: rate * 100, + }}, nil +} + +// computeItHorizon returns the planning window, capped by the agreement's validity. +func computeItHorizon(now time.Time, agreement krakengql.ItAgreement, planDays int) (planningHorizon, error) { + start := now + end := now.AddDate(0, 0, planDays) + + if agreement.ValidFrom.After(end) || (!agreement.ValidTo.IsZero() && agreement.ValidTo.Before(start)) { + return planningHorizon{}, errors.New("agreement is not valid for the planning horizon") + } + + if agreement.ValidFrom.After(start) { + start = agreement.ValidFrom + } + + if !agreement.ValidTo.IsZero() && agreement.ValidTo.Before(end) { + end = agreement.ValidTo + } + + return planningHorizon{start: start, end: end}, nil +} diff --git a/tariff/octopusit_test.go b/tariff/octopusit_test.go index d8b27a3e8..146b5771f 100644 --- a/tariff/octopusit_test.go +++ b/tariff/octopusit_test.go @@ -2,7 +2,10 @@ package tariff import ( "testing" + "time" + krakengql "github.com/evcc-io/evcc/tariff/octopuskraken/graphql" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,3 +44,57 @@ func TestOctopusItConfigParse(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "missing account number") } + +// fixedItAgreement builds a FIXED_SINGLE_RATE agreement matching the shape +// confirmed against the live Kraken IT API (issue #31505). +func fixedItAgreement(validFrom, validTo time.Time) krakengql.ItAgreement { + agr := krakengql.ItAgreement{ + IsActive: true, + ValidFrom: validFrom, + ValidTo: validTo, + } + agr.Product.Code = "000129ESFML09XXXXXXXXOCTOFIXv109" + agr.Product.Prices = krakengql.ElectricityProductPrices{ + ProductType: "FIXED_SINGLE_RATE", + ConsumptionCharge: "0.09900", + } + return agr +} + +func TestRatesForItAgreementFixed(t *testing.T) { + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + validFrom := now.AddDate(0, -9, 0) + validTo := now.AddDate(0, 3, 0) + agr := fixedItAgreement(validFrom, validTo) + + rates, err := ratesForItAgreement(agr, now) + require.NoError(t, err) + require.Len(t, rates, 1) + + r := rates[0] + assert.Equal(t, now, r.ValidFrom) + assert.Equal(t, now.AddDate(0, 0, planDays), r.ValidTo) + assert.InDelta(t, 9.9, r.GrossUnitRateCentsPerKwh, 1e-9) +} + +func TestRatesForItAgreementCapsToAgreementValidity(t *testing.T) { + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + validTo := now.AddDate(0, 0, 2) + agr := fixedItAgreement(now.AddDate(0, -1, 0), validTo) + + rates, err := ratesForItAgreement(agr, now) + require.NoError(t, err) + require.Len(t, rates, 1) + assert.Equal(t, validTo, rates[0].ValidTo) +} + +func TestRatesForItAgreementRejectsTimeOfUse(t *testing.T) { + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + agr := fixedItAgreement(now.AddDate(0, -1, 0), now.AddDate(0, 1, 0)) + agr.Product.Prices.ConsumptionChargeF2 = "0.12000" + agr.Product.Prices.ConsumptionChargeF3 = "0.07000" + + _, err := ratesForItAgreement(agr, now) + require.Error(t, err) + assert.Contains(t, err.Error(), "time-of-use") +} diff --git a/tariff/octopuskraken/graphql/it_api.go b/tariff/octopuskraken/graphql/it_api.go new file mode 100644 index 000000000..c49519312 --- /dev/null +++ b/tariff/octopuskraken/graphql/it_api.go @@ -0,0 +1,33 @@ +package graphql + +import ( + "context" + "errors" + "time" +) + +// ItActiveAgreement queries the Italian Kraken API and returns the active +// electricity supply agreement, including its product's resolved prices. +func (c *Client) ItActiveAgreement() (ItAgreement, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + var q itGetAgreements + if err := c.Client.Query(ctx, &q, map[string]any{ + "accountNumber": c.accountNumber, + }); err != nil { + return ItAgreement{}, err + } + + for _, property := range q.Account.Properties { + for _, sp := range property.ElectricitySupplyPoints { + for _, edge := range sp.Agreements.Edges { + if edge.Node.IsActive { + return edge.Node, nil + } + } + } + } + + return ItAgreement{}, errors.New("no active agreement found") +} diff --git a/tariff/octopuskraken/graphql/it_types.go b/tariff/octopuskraken/graphql/it_types.go new file mode 100644 index 000000000..6c95d2b6c --- /dev/null +++ b/tariff/octopuskraken/graphql/it_types.go @@ -0,0 +1,49 @@ +package graphql + +import "time" + +// ItAgreement is an electricity supply agreement on Octopus Italy's schema. +// Rates live on the product directly - agreementRates isn't reachable with a customer token. +type ItAgreement struct { + ID int + IsActive bool + ValidFrom time.Time + ValidTo time.Time + Product struct { + ElectricityProductType `graphql:"... on ElectricityProductType"` + } `graphql:"product"` +} + +// ElectricityProductType is the Italian Kraken schema's product type, +// carrying the customer-facing consumption prices. +type ElectricityProductType struct { + Code string + Prices ElectricityProductPrices +} + +// ElectricityProductPrices holds customer-facing prices, already in €/kWh. +// ConsumptionChargeF2/F3 are populated only for time-of-use products. +type ElectricityProductPrices struct { + ProductType string + ConsumptionCharge string + ConsumptionChargeF2 string + ConsumptionChargeF3 string + ConsumptionChargeUnits string +} + +// itGetAgreements uses electricitySupplyPoints (not electricityMalos) and a +// paginated agreements connection (edges/node), unlike Germany's plain list. +type itGetAgreements struct { + Account struct { + Properties []struct { + ElectricitySupplyPoints []struct { + Pod string + Agreements struct { + Edges []struct { + Node ItAgreement + } + } `graphql:"agreements(first: 10)"` + } + } + } `graphql:"account(accountNumber: $accountNumber)"` +} diff --git a/templates/definition/tariff/octopus-it.yaml b/templates/definition/tariff/octopus-it.yaml index df10bac19..73735a498 100644 --- a/templates/definition/tariff/octopus-it.yaml +++ b/templates/definition/tariff/octopus-it.yaml @@ -1,4 +1,5 @@ template: octopus-it +deprecated: true products: - brand: Octopus Energy description: