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 }