From 6a87f3727b7ca1ef4fe265d8823a262505eab5b6 Mon Sep 17 00:00:00 2001 From: Jeffrey Borg Date: Wed, 9 Apr 2025 17:36:54 +1000 Subject: [PATCH] Tariffs: add month in fixed tariff zones to allow for seasonal tariffs (#20484) --- tariff/fixed.go | 26 ++++--- tariff/fixed/month.go | 133 +++++++++++++++++++++++++++++++++++ tariff/fixed/month_enumer.go | 118 +++++++++++++++++++++++++++++++ tariff/fixed/zone.go | 11 +-- tariff/fixed/zone_test.go | 36 ++++++++-- 5 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 tariff/fixed/month.go create mode 100644 tariff/fixed/month_enumer.go diff --git a/tariff/fixed.go b/tariff/fixed.go index 1b4d53f79..d0a5684ea 100644 --- a/tariff/fixed.go +++ b/tariff/fixed.go @@ -28,8 +28,8 @@ func NewFixedFromConfig(other map[string]interface{}) (api.Tariff, error) { var cc struct { Price float64 Zones []struct { - Price float64 - Days, Hours string + Price float64 + Days, Hours, Months string } } @@ -48,6 +48,11 @@ func NewFixedFromConfig(other map[string]interface{}) (api.Tariff, error) { return nil, err } + months, err := fixed.ParseMonths(z.Months) + if err != nil { + return nil, err + } + hours, err := fixed.ParseTimeRanges(z.Hours) if err != nil && z.Hours != "" { return nil, err @@ -55,17 +60,19 @@ func NewFixedFromConfig(other map[string]interface{}) (api.Tariff, error) { if len(hours) == 0 { t.zones = append(t.zones, fixed.Zone{ - Price: z.Price, - Days: days, + Price: z.Price, + Days: days, + Months: months, }) continue } for _, h := range hours { t.zones = append(t.zones, fixed.Zone{ - Price: z.Price, - Days: days, - Hours: h, + Price: z.Price, + Days: days, + Months: months, + Hours: h, }) } } @@ -86,14 +93,15 @@ func (t *Fixed) Rates() (api.Rates, error) { start := now.With(t.clock.Now().Local()).BeginningOfDay() for i := range 7 { + dayStart := start.AddDate(0, 0, i) dow := fixed.Day((int(start.Weekday()) + i) % 7) + month := fixed.Month(dayStart.Month() - 1) - zones := t.zones.ForDay(dow) + zones := t.zones.ForDayAndMonth(dow, month) if len(zones) == 0 { return nil, fmt.Errorf("no zones for weekday %d", dow) } - dayStart := start.AddDate(0, 0, i) markers := zones.TimeTableMarkers() for i, m := range markers { diff --git a/tariff/fixed/month.go b/tariff/fixed/month.go new file mode 100644 index 000000000..2bd1c6195 --- /dev/null +++ b/tariff/fixed/month.go @@ -0,0 +1,133 @@ +package fixed + +import ( + "errors" + "fmt" + "slices" + "strconv" + "strings" +) + +//go:generate go tool enumer -type Month +type Month int + +const ( + January Month = iota + February + March + April + May + June + July + August + September + October + November + December +) + +var Year = []Month{January, February, March, April, May, June, July, August, September, October, November, December} + +var shortMonths = map[string]Month{ + // english + "jan": January, + "feb": February, + "mar": March, + "apr": April, + "may": May, + "jun": June, + "jul": July, + "aug": August, + "sep": September, + "oct": October, + "nov": November, + "dec": December, + // german + // "jan": January, + // "feb": February, + "mär": March, + // "apr": April, + "mai": May, + // "jun": June, + // "jul": July, + // "aug": August, + // "sep": September, + "okt": October, + // "nov": November, + "dez": December, +} + +// ParseMonth parses a single month +func ParseMonth(s string) (Month, error) { + s = strings.ToLower(strings.TrimSpace(s)) + + // full string + if m, err := MonthString(s); err == nil { + return m, nil + } + + // short string + if m, ok := shortMonths[s]; ok { + return m, nil + } + + m, err := strconv.Atoi(s) + if m < 1 || m > 12 || err != nil { + return 0, fmt.Errorf("invalid month: %s", s) + } + + return Month(m - 1), nil +} + +// ParseMonths converts a months string into a slice of individual months +// Months format: +// +// month[-month][, ...] +func ParseMonths(s string) ([]Month, error) { + var res []Month + + for _, segment := range strings.Split(s, ",") { + fromto := strings.SplitN(segment, "-", 2) + if len(fromto) == 0 { + return nil, fmt.Errorf("invalid month range: %s", segment) + } + + fromToFrom := fromto[0] + + // single empty segment + if len(fromto) == 1 && strings.TrimSpace(fromToFrom) == "" { + return slices.Clone(Year), nil + } + + from, err := ParseMonth(fromToFrom) + if err != nil { + return nil, err + } + res = append(res, from) + + if len(fromto) == 2 { + to, err := ParseMonth(fromto[1]) + if err != nil { + return nil, err + } + + if to < from { + to += 12 + } + + for m := from + 1; m <= to; m++ { + res = append(res, m%12) + } + } + } + + if len(res) > 12 { + return nil, errors.New("too many months") + } + + if len(slices.Compact(slices.Sorted(slices.Values(res)))) < len(res) { + return nil, errors.New("duplicate months") + } + + return res, nil +} diff --git a/tariff/fixed/month_enumer.go b/tariff/fixed/month_enumer.go new file mode 100644 index 000000000..87afa817e --- /dev/null +++ b/tariff/fixed/month_enumer.go @@ -0,0 +1,118 @@ +// Code generated by "enumer -type Month"; DO NOT EDIT. + +package fixed + +import ( + "fmt" + "strings" +) + +const _MonthName = "JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember" + +var _MonthIndex = [...]uint8{0, 7, 15, 20, 25, 28, 32, 36, 42, 51, 58, 66, 74} + +const _MonthLowerName = "januaryfebruarymarchaprilmayjunejulyaugustseptemberoctobernovemberdecember" + +func (i Month) String() string { + if i < 0 || i >= Month(len(_MonthIndex)-1) { + return fmt.Sprintf("Month(%d)", i) + } + return _MonthName[_MonthIndex[i]:_MonthIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _MonthNoOp() { + var x [1]struct{} + _ = x[January-(0)] + _ = x[February-(1)] + _ = x[March-(2)] + _ = x[April-(3)] + _ = x[May-(4)] + _ = x[June-(5)] + _ = x[July-(6)] + _ = x[August-(7)] + _ = x[September-(8)] + _ = x[October-(9)] + _ = x[November-(10)] + _ = x[December-(11)] +} + +var _MonthValues = []Month{January, February, March, April, May, June, July, August, September, October, November, December} + +var _MonthNameToValueMap = map[string]Month{ + _MonthName[0:7]: January, + _MonthLowerName[0:7]: January, + _MonthName[7:15]: February, + _MonthLowerName[7:15]: February, + _MonthName[15:20]: March, + _MonthLowerName[15:20]: March, + _MonthName[20:25]: April, + _MonthLowerName[20:25]: April, + _MonthName[25:28]: May, + _MonthLowerName[25:28]: May, + _MonthName[28:32]: June, + _MonthLowerName[28:32]: June, + _MonthName[32:36]: July, + _MonthLowerName[32:36]: July, + _MonthName[36:42]: August, + _MonthLowerName[36:42]: August, + _MonthName[42:51]: September, + _MonthLowerName[42:51]: September, + _MonthName[51:58]: October, + _MonthLowerName[51:58]: October, + _MonthName[58:66]: November, + _MonthLowerName[58:66]: November, + _MonthName[66:74]: December, + _MonthLowerName[66:74]: December, +} + +var _MonthNames = []string{ + _MonthName[0:7], + _MonthName[7:15], + _MonthName[15:20], + _MonthName[20:25], + _MonthName[25:28], + _MonthName[28:32], + _MonthName[32:36], + _MonthName[36:42], + _MonthName[42:51], + _MonthName[51:58], + _MonthName[58:66], + _MonthName[66:74], +} + +// MonthString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func MonthString(s string) (Month, error) { + if val, ok := _MonthNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _MonthNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to Month values", s) +} + +// MonthValues returns all values of the enum +func MonthValues() []Month { + return _MonthValues +} + +// MonthStrings returns a slice of all String values of the enum +func MonthStrings() []string { + strs := make([]string, len(_MonthNames)) + copy(strs, _MonthNames) + return strs +} + +// IsAMonth returns "true" if the value is listed in the enum definition. "false" otherwise +func (i Month) IsAMonth() bool { + for _, v := range _MonthValues { + if i == v { + return true + } + } + return false +} diff --git a/tariff/fixed/zone.go b/tariff/fixed/zone.go index ba67c59bb..4b1dea8c4 100644 --- a/tariff/fixed/zone.go +++ b/tariff/fixed/zone.go @@ -5,9 +5,10 @@ import ( ) type Zone struct { - Price float64 - Days []Day - Hours TimeRange + Price float64 + Days []Day + Hours TimeRange + Months []Month } type Zones []Zone @@ -29,10 +30,10 @@ func (r Zones) Swap(i, j int) { } // ForDay returns the zones for given day in ascending order -func (r Zones) ForDay(day Day) Zones { +func (r Zones) ForDayAndMonth(day Day, month Month) Zones { var zones Zones for _, z := range r { - if slices.Contains(z.Days, day) || len(z.Days) == 0 { + if (slices.Contains(z.Days, day) || len(z.Days) == 0) && (slices.Contains(z.Months, month) || len(z.Months) == 0) { zones = append(zones, z) } } diff --git a/tariff/fixed/zone_test.go b/tariff/fixed/zone_test.go index 6a9f293e7..867144132 100644 --- a/tariff/fixed/zone_test.go +++ b/tariff/fixed/zone_test.go @@ -13,10 +13,38 @@ func TestZonesForDay(t *testing.T) { {Days: []Day{Wednesday, Thursday, Friday}}, } - assert.Len(t, zones.ForDay(Sunday), 1) - assert.Len(t, zones.ForDay(Monday), 2) - assert.Len(t, zones.ForDay(Wednesday), 3) - assert.Len(t, zones.ForDay(Thursday), 2) + assert.Len(t, zones.ForDayAndMonth(Sunday, April), 1) + assert.Len(t, zones.ForDayAndMonth(Monday, April), 2) + assert.Len(t, zones.ForDayAndMonth(Wednesday, April), 3) + assert.Len(t, zones.ForDayAndMonth(Thursday, April), 2) +} + +func TestZonesForDayAndMonth(t *testing.T) { + zones := Zones{ + {Days: nil, Months: nil}, // Applies to all days and months + {Days: []Day{Monday, Tuesday, Wednesday}, Months: []Month{January, February, March, April}}, + {Days: []Day{Wednesday, Thursday, Friday}, Months: []Month{April, May, June}}, + } + + // Test for specific day and month combinations + assert.Len(t, zones.ForDayAndMonth(Sunday, April), 1) + assert.Len(t, zones.ForDayAndMonth(Monday, January), 2) + assert.Len(t, zones.ForDayAndMonth(Wednesday, April), 3) + assert.Len(t, zones.ForDayAndMonth(Thursday, May), 2) + assert.Len(t, zones.ForDayAndMonth(Saturday, July), 1) +} + +func TestZonesForMonth(t *testing.T) { + zones := Zones{ + {Months: nil}, + {Months: []Month{January, February, March, April}}, + {Months: []Month{April, May, June}}, + } + + // Test for specific months + assert.Len(t, zones.ForDayAndMonth(Sunday, January), 2) + assert.Len(t, zones.ForDayAndMonth(Sunday, April), 3) + assert.Len(t, zones.ForDayAndMonth(Sunday, July), 1) } func TestZonesTimeTableMarkers(t *testing.T) {