octopus-it: fix account query for Italy's Kraken schema (#31656)

This commit is contained in:
andig 2026-07-12 13:26:33 +02:00 • committed by GitHub
parent 840f83a9db
commit 017d77d0be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 190 additions and 3 deletions

View file

@ -2,6 +2,7 @@ package tariff
import ( import (
"errors" "errors"
"fmt"
"slices" "slices"
"sync" "sync"
"time" "time"
@ -13,7 +14,7 @@ import (
) )
// OctopusIt is an api.Tariff implementation for Octopus Energy Italy, reusing // 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 { type OctopusIt struct {
log *util.Logger log *util.Logger
gqlClient *krakengql.Client gqlClient *krakengql.Client
@ -84,14 +85,14 @@ func (t *OctopusIt) run(done chan error) {
var rates []RatePeriod var rates []RatePeriod
if err := backoff.Retry(func() error { if err := backoff.Retry(func() error {
agr, err := t.gqlClient.ActiveAgreement() agr, err := t.gqlClient.ItActiveAgreement()
if err != nil { if err != nil {
if errors.Is(err, krakengql.ErrAuthFailed) { if errors.Is(err, krakengql.ErrAuthFailed) {
return backoff.Permanent(err) return backoff.Permanent(err)
} }
return backoffPermanentError(err) return backoffPermanentError(err)
} }
rates, err = ratesForAgreement(agr, time.Now()) rates, err = ratesForItAgreement(agr, time.Now())
return backoffPermanentError(err) return backoffPermanentError(err)
}, bo()); err != nil { }, bo()); err != nil {
if reportError(&once, done, err) { if reportError(&once, done, err) {
@ -132,3 +133,49 @@ func (t *OctopusIt) Rates() (api.Rates, error) {
func (t *OctopusIt) Type() api.TariffType { func (t *OctopusIt) Type() api.TariffType {
return api.TariffTypePriceForecast 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
}

View file

@ -2,7 +2,10 @@ package tariff
import ( import (
"testing" "testing"
"time"
krakengql "github.com/evcc-io/evcc/tariff/octopuskraken/graphql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -41,3 +44,57 @@ func TestOctopusItConfigParse(t *testing.T) {
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "missing account number") 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")
}

View file

@ -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")
}

View file

@ -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)"`
}

View file

@ -1,4 +1,5 @@
template: octopus-it template: octopus-it
deprecated: true
products: products:
- brand: Octopus Energy - brand: Octopus Energy
description: description: