Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions tariff/fixed.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,35 @@ func NewFixedFromConfig(other map[string]any) (api.Tariff, error) {
}
}

sort.Sort(t.zones)

// prepend catch-all zone
t.zones = append([]fixed.Zone{
{Price: cc.Price}, // full week is implicit
}, t.zones...)

sort.Sort(t.zones)

for i := 0; i < len(t.zones); i++ {
for j := i + 1; j < len(t.zones); j++ {
// Only warn if specificity is equal AND months/days/hours overlap
if t.zones[i].Specificity() == t.zones[j].Specificity() &&
monthsOverlap(t.zones[i], t.zones[j]) &&
daysOverlap(t.zones[i], t.zones[j]) &&
hoursOverlap(t.zones[i], t.zones[j]) {
fmt.Printf(
"WARNING: ambiguous zones detected: zone %d (%.2f) and zone %d (%.2f)\n", i, t.zones[i].Price, j, t.zones[j].Price,
)
}
}
}

return t, nil
}

// Rates implements the api.Tariff interface
func (t *Fixed) Rates() (api.Rates, error) {
var res api.Rates

start := now.With(t.clock.Now().Local()).BeginningOfDay()
start := now.With(t.clock.Now()).BeginningOfDay()
for i := range 7 {
dayStart := start.AddDate(0, 0, i)
dow := fixed.Day((int(start.Weekday()) + i) % 7)
Expand All @@ -108,9 +122,9 @@ func (t *Fixed) Rates() (api.Rates, error) {
ts := dayStart.Add(time.Minute * time.Duration(m.Minutes()))

var zone *fixed.Zone
for j := len(zones) - 1; j >= 0; j-- {
if zones[j].Hours.Contains(m) {
zone = &zones[j]
for i := range zones {
if zones[i].Hours.Contains(m) {
zone = &zones[i]
break
}
}
Expand Down
18 changes: 18 additions & 0 deletions tariff/fixed/timerange.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,21 @@ func ParseTimeRanges(s string) ([]TimeRange, error) {

return res, nil
}

// Overlaps returns true if two time ranges intersect (handles midnight wrap)
func (tr TimeRange) Overlaps(other TimeRange) bool {
startA := tr.From.Hour*60 + tr.From.Min
endA := tr.To.Hour*60 + tr.To.Min
startB := other.From.Hour*60 + other.From.Min
endB := other.To.Hour*60 + other.To.Min

// wrap over midnight
if endA <= startA {
endA += 24 * 60
}
if endB <= startB {
endB += 24 * 60
}

return startA < endB && startB < endA
}
28 changes: 28 additions & 0 deletions tariff/fixed/zone.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ func (r Zones) Len() int {
}

func (r Zones) Less(i, j int) bool {
specI := r[i].Specificity()
specJ := r[j].Specificity()

if specI != specJ {
return specI > specJ
}

if r[i].Hours.From.Minutes() == r[j].Hours.From.Minutes() {
return r[i].Hours.To.Minutes() > r[j].Hours.To.Minutes()
}
Expand Down Expand Up @@ -78,5 +85,26 @@ HOURS:
res = append(res, HourMin{Hour: hour, Min: 0})
}

// Sort markers by time to ensure correct ordering
slices.SortFunc(res, func(a, b HourMin) int {
return a.Minutes() - b.Minutes()
})

return res
}

// Specificity returns a weighted specificity score.
// Higher value = more specific.
func (z Zone) Specificity() int {
score := 0
if len(z.Months) > 0 && len(z.Months) < 12 {
score += 4
}
if len(z.Days) > 0 && len(z.Days) < 7 {
score += 2
}
if !z.Hours.From.IsNil() || !z.Hours.To.IsNil() {
score += 1
}
return score
}
83 changes: 83 additions & 0 deletions tariff/fixed_specificity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package tariff

import (
"testing"
"time"

"github.com/benbjohnson/clock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFixedSpecificity(t *testing.T) {
at, err := NewFixedFromConfig(map[string]any{
"price": 0.30,
"zones": []struct {
Price float64
Hours string
Months string
}{
// REVERSED: specific first, general second
// Without MoreSpecific, this will fail!
{0.10, "0-5", "Jan-Mar,Oct-Dec"}, // specific (winter only)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am Ende müssen vmtl. auch Überlappungen gehandhabt werden? In jedem Fall wäre es schön diese- wenn nicht vergleichbar- als Fehler identifizieren zu können.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aber nur wenn wirklich "ambigious"? Das braucht ein paar Helper

{0.20, "0-5", ""}, // general (all year)
},
})
require.NoError(t, err)

testCases := []struct {
month time.Month
expected float64
}{
{time.January, 0.10}, // winter: specific zone wins
{time.June, 0.20}, // summer: general zone
{time.December, 0.10}, // winter: specific zone wins
}

for _, tc := range testCases {
clock := clock.NewMock()
at.(*Fixed).clock = clock
clock.Set(time.Date(2025, tc.month, 15, 3, 0, 0, 0, time.UTC))

rr, err := at.Rates()
require.NoError(t, err)

r, err := rr.At(clock.Now())
require.NoError(t, err)

assert.Equal(t, tc.expected, r.Value,
"TZ=%s, %s: expected %.2f", time.UTC, tc.month, tc.expected)
}
}

func TestPartiallyOverlappingMonths(t *testing.T) {
at, err := NewFixedFromConfig(map[string]any{
"price": 0.0,
"zones": []struct {
Price float64
Hours string
Months string
}{
{0.10, "0-5", "Jan"},
{0.20, "0-5", "Feb"},
{0.30, "0-5", "Jan-Mar"},
},
})
require.NoError(t, err)

clock := clock.NewMock()
tf := at.(*Fixed)
tf.clock = clock

// Test for January → should detect ambiguity (Zone 0 + Zone 2)
clock.Set(time.Date(2025, time.January, 10, 1, 0, 0, 0, time.UTC))
_, _ = tf.Rates() // triggers warning

// Test for February → should detect ambiguity (Zone 1 + Zone 2)
clock.Set(time.Date(2025, time.February, 10, 1, 0, 0, 0, time.UTC))
_, _ = tf.Rates() // triggers warning

// Test for March → only Zone 2 applies → no warning
clock.Set(time.Date(2025, time.March, 10, 1, 0, 0, 0, time.UTC))
_, _ = tf.Rates()
}
57 changes: 57 additions & 0 deletions tariff/fixed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,60 @@ func TestFixedSplitZones(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, expect, rates)
}

func TestFixedMonthsSorting(t *testing.T) {
at, err := NewFixedFromConfig(map[string]any{
"zones": []struct {
Price float64
Hours string
Months string
}{
{0.1, "0-5", ""}, // all year
{0.2, "5-0", ""}, // all year
{0.3, "2-4", "Jun"}, // Jun only
{0.4, "18-20", "Jun"}, // Jun only
// TODO: specific days
},
})
require.NoError(t, err)

tc := []struct {
m, d, h int
rate float64
}{
// all year
{1, 1, 0, 0.1},
{1, 1, 2, 0.1},
{1, 1, 5, 0.2},
{1, 1, 18, 0.2},

// Jun only
{6, 1, 0, 0.1},
{6, 1, 2, 0.3},
{6, 1, 5, 0.2},
{6, 1, 18, 0.4},
}

// Test both UTC and Local timezones to verify clock timezone is respected
timezones := []*time.Location{time.UTC, time.Local}
for _, tz := range timezones {
t.Run(tz.String(), func(t *testing.T) {
for _, tc := range tc {
clock := clock.NewMock()
at.(*Fixed).clock = clock

clock.Set(time.Date(2025, time.Month(tc.m), tc.d, 0, 0, 0, 0, tz))

rr, err := at.Rates()
require.NoError(t, err)

r, err := rr.At(clock.Now().Add(time.Duration(tc.h) * time.Hour))
require.NoError(t, err)

assert.Equal(t, tc.rate, r.Value,
"TZ=%s, %04d-%02d-%02d %02d:00",
tz, 2025, tc.m, tc.d, tc.h)
}
})
}
}
36 changes: 36 additions & 0 deletions tariff/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/tariff/fixed"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/request"
Expand Down Expand Up @@ -90,3 +91,38 @@ func runOrError[T any, I runnable[T]](t I) (*T, error) {

return t, nil
}

func monthsOverlap(a, b fixed.Zone) bool {
if len(a.Months) == 0 || len(b.Months) == 0 {
return true // all year overlaps
}
for _, m1 := range a.Months {
for _, m2 := range b.Months {
if m1 == m2 {
return true
}
}
}
return false
}

func daysOverlap(a, b fixed.Zone) bool {
if len(a.Days) == 0 || len(b.Days) == 0 {
return true
}
for _, d1 := range a.Days {
for _, d2 := range b.Days {
if d1 == d2 {
return true
}
}
}
return false
}

func hoursOverlap(a, b fixed.Zone) bool {
if a.Hours.IsNil() || b.Hours.IsNil() {
return true
}
return a.Hours.Overlaps(b.Hours)
}
Loading