tariff/amber.go
Rama master · réplica de lectura del repositorio original.
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 {
if reportError(&once, done, err) {
return
}
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
}