evcc-io/tariff/amber.go
2025-10-31 15:25:53 +00:00

254 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tariff
import (
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/tariff/amber"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
)
type Amber struct {
*embed
*request.Helper
log *util.Logger
uri string
channel string
data *util.Monitor[api.Rates]
}
var _ api.Tariff = (*Amber)(nil)
func init() {
registry.Add("amber", NewAmberFromConfig)
}
func NewAmberFromConfig(other map[string]any) (api.Tariff, error) {
var cc struct {
embed `mapstructure:",squash"`
Token string
SiteID string
Channel string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.Token == "" {
return nil, api.ErrMissingToken
}
if cc.SiteID == "" {
return nil, errors.New("missing siteid")
}
if cc.Channel == "" {
return nil, errors.New("missing channel")
}
log := util.NewLogger("amber").Redact(cc.Token)
t := &Amber{
embed: &cc.embed,
log: log,
Helper: request.NewHelper(log),
uri: fmt.Sprintf(amber.URI, strings.ToUpper(cc.SiteID)),
channel: strings.ToLower(cc.Channel),
data: util.NewMonitor[api.Rates](2 * time.Hour),
}
t.Client.Transport = &transport.Decorator{
Base: t.Client.Transport,
Decorator: transport.DecorateHeaders(map[string]string{
"Authorization": "Bearer " + cc.Token,
}),
}
return runOrError(t)
}
func (t *Amber) run(done chan error) {
var once sync.Once
for tick := time.Tick(time.Minute); ; <-tick {
var res []amber.PriceInfo
if err := backoff.Retry(func() error {
return backoffPermanentError(t.GetJSON(t.uri, &res))
}, bo()); err != nil {
once.Do(func() { done <- err })
t.log.ERROR.Println(err)
continue
}
// Create and sort time-ordered list of all Amber intervals
var intervals []struct {
start, end time.Time
value float64
isCurrent bool
}
for _, r := range res {
if t.channel == strings.ToLower(r.ChannelType) {
startTime, _ := time.Parse("2006-01-02T15:04:05Z", r.StartTime)
endTime, _ := time.Parse("2006-01-02T15:04:05Z", r.EndTime)
value := r.PerKwh / 1e2
if r.AdvancedPrice != nil {
value = r.AdvancedPrice.Predicted / 1e2
}
// Invert feed-in prices to match evcc expectations (positive = paid for exports)
if t.channel == "feedin" {
value = -value
}
intervals = append(intervals, struct {
start, end time.Time
value float64
isCurrent bool
}{
start: startTime.Local(),
end: endTime.Local(),
value: value,
isCurrent: r.Type == "CurrentInterval",
})
}
}
if len(intervals) == 0 {
mergeRates(t.data, nil)
once.Do(func() { close(done) })
continue
}
// Sort intervals by start time to ensure correct processing
slices.SortFunc(intervals, func(a, b struct {
start, end time.Time
value float64
isCurrent bool
}) int {
return a.start.Compare(b.start)
})
data := t.buildSlotRates(intervals)
mergeRates(t.data, data)
once.Do(func() { close(done) })
}
}
// buildSlotRates converts Amber intervals into 15-minute slots using bucket sharding
// to avoid O(slots × intervals) complexity and only create slots with actual data
func (t *Amber) buildSlotRates(intervals []struct {
start, end time.Time
value float64
isCurrent bool
}) api.Rates {
// Build slot buckets using sharding approach
type bucket struct {
totalSecs float64
weightedSum float64
current *float64
}
buckets := make(map[time.Time]*bucket)
for _, iv := range intervals {
// Truncate start to slot boundary
slot := iv.start.Truncate(SlotDuration)
end := iv.end
for slot.Before(end) {
next := slot.Add(SlotDuration)
// Compute overlap [max(slot, iv.start), min(next, iv.end))
overlapStart := slot
if iv.start.After(slot) {
overlapStart = iv.start
}
overlapEnd := next
if iv.end.Before(next) {
overlapEnd = iv.end
}
overlapSecs := overlapEnd.Sub(overlapStart).Seconds()
b, ok := buckets[slot]
if !ok {
b = &bucket{}
buckets[slot] = b
}
if iv.isCurrent {
// Current interval overrides the entire slot
b.current = &iv.value
} else {
// Add to weighted average
b.weightedSum += iv.value * overlapSecs
b.totalSecs += overlapSecs
}
slot = next
}
}
// Convert buckets to sorted rates, skipping empty slots
var data api.Rates
for start, b := range buckets {
var finalValue float64
hasValue := false
if b.current != nil {
finalValue = *b.current
hasValue = true
} else if b.totalSecs > 0 {
finalValue = b.weightedSum / b.totalSecs
hasValue = true
}
// Only add slots with actual data
if hasValue {
data = append(data, api.Rate{
Start: start,
End: start.Add(SlotDuration),
Value: finalValue,
})
}
}
// Sort by start time
slices.SortFunc(data, func(a, b api.Rate) int {
return a.Start.Compare(b.Start)
})
return data
}
// Rates implements the api.Tariff interface
func (t *Amber) Rates() (api.Rates, error) {
var res api.Rates
err := t.data.GetFunc(func(val api.Rates) {
res = slices.Clone(val)
})
return res, err
}
func (t *Amber) Unit() string {
return "AUD"
}
// Type returns the tariff type
func (t *Amber) Type() api.TariffType {
return api.TariffTypePriceForecast
}