octopus-it: fix account query for Italy's Kraken schema (#31656)
This commit is contained in:
parent
840f83a9db
commit
017d77d0be
5 changed files with 190 additions and 3 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
33
tariff/octopuskraken/graphql/it_api.go
Normal file
33
tariff/octopuskraken/graphql/it_api.go
Normal 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")
|
||||
}
|
||||
49
tariff/octopuskraken/graphql/it_types.go
Normal file
49
tariff/octopuskraken/graphql/it_types.go
Normal 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)"`
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
template: octopus-it
|
||||
deprecated: true
|
||||
products:
|
||||
- brand: Octopus Energy
|
||||
description:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue