diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6e33454 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,6 @@ +{ + "tasks": { + "build": "dotnet build", + "test": "dotnet test" + } +} \ No newline at end of file diff --git a/README.md b/README.md index e1e95d7..3e5fe30 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ See below for how to configure the environment variables appropriately ### Fixed Price +The Fixed Price provider allows you to use TeslaMateAgile if you have a fixed price for electricity at different times of the day. This is useful if you have a simple time-of-use tariff that isn't supported by the other providers. + ```yaml - TeslaMate__EnergyProvider=FixedPrice - FixedPrice__TimeZone=Europe/London # IANA (tz database) time zone code, used for below times @@ -71,6 +73,23 @@ See below for how to configure the environment variables appropriately - FixedPrice__Prices__4=06:00-08:00=0.02 ``` +### Fixed Price (Weekly) + +The Fixed Price Weekly provider is similar to the Fixed Price provider but allows you to set different prices for different days of the week. This is useful if your electricity tariff changes on different days of the week but is consistent week-to-week, e.g. a weekday / weekend tariff. + +```yaml +- TeslaMate__EnergyProvider=FixedPriceWeekly +- FixedPriceWeekly__TimeZone=Europe/London # IANA (tz database) time zone code, used for below times +- FixedPriceWeekly__Prices__0=Mon-Wed=08:00-13:00=0.1559 # Cost is in your currency e.g. pounds, euros, dollars (not pennies, cents, etc) +- FixedPriceWeekly__Prices__1=Mon-Wed=13:00-08:00=0.05 # Day(s) of the week can be comma separated or a range (e.g. Mon-Fri or Mon,Wed,Fri) +- FixedPriceWeekly__Prices__6=Thu=0.22 # The time range is optional and will be used for the whole day if unspecified +- FixedPriceWeekly__Prices__3=Fri,Sat=08:00-18:00=0.1559 # You can have as many as these as you need +- FixedPriceWeekly__Prices__4=Fri,Sat=18:00-08:00=0.04 +- FixedPriceWeekly__Prices__5=Sun=12:00-18:00=0.1559 +- FixedPriceWeekly__Prices__7=Sun=18:00-08:00=0.04 +- FixedPriceWeekly__Prices__8=Sun=08:00-12:00=0.1559 +``` + ### aWATTar ```yaml diff --git a/TeslaMateAgile.Tests/Services/FixedPriceWeeklyServiceTests.cs b/TeslaMateAgile.Tests/Services/FixedPriceWeeklyServiceTests.cs new file mode 100644 index 0000000..6ef4e25 --- /dev/null +++ b/TeslaMateAgile.Tests/Services/FixedPriceWeeklyServiceTests.cs @@ -0,0 +1,405 @@ +using Microsoft.Extensions.Options; +using NUnit.Framework; +using NUnit.Framework.Internal; +using TeslaMateAgile.Data; +using TeslaMateAgile.Data.Options; +using TeslaMateAgile.Services; + +namespace TeslaMateAgile.Tests.Services +{ + [TestFixture] + public class FixedPriceWeeklyServiceTests + { + private FixedPriceWeeklyService Setup(string timeZone, List prices) + { + var options = Options.Create(new FixedPriceWeeklyOptions { TimeZone = timeZone, Prices = prices }); + return new FixedPriceWeeklyService(options); + } + + private static readonly object[][] FixedPriceWeeklyService_GetPriceData_Cases = new object[][] + { + new object[] + { + "MondayToNextTuesday", + "Europe/London", + new List + { + "Mon-Fri=08:00-13:00=0.1559", + "Mon-Fri=13:00-20:00=0.05", + "Mon-Fri=20:00-03:30=0.04", + "Mon-Fri=03:30-06:00=0.035", + "Mon-Fri=06:00-08:00=0.02", + "Sat=18:00-20:00=0.05", + "Sat=20:00-18:00=0.025", + "Sun=0.042" + }, + new DateTimeOffset(new DateTime(2023, 1, 2, 2, 0, 0, DateTimeKind.Utc)), // Monday + new DateTimeOffset(new DateTime(2023, 1, 10, 4, 0, 0, DateTimeKind.Utc)), // Next Tuesday + new List + { + // Monday from 2am + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 3, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 3, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 6, 0, 0, DateTimeKind.Utc)), Value = 0.035m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 6, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 8, 0, 0, DateTimeKind.Utc)), Value = 0.02m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 13, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 20, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 20, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Tuesday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 3, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 3, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 6, 0, 0, DateTimeKind.Utc)), Value = 0.035m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 6, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 8, 0, 0, DateTimeKind.Utc)), Value = 0.02m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 13, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 20, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 20, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Wednesday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 3, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 3, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 6, 0, 0, DateTimeKind.Utc)), Value = 0.035m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 6, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 8, 0, 0, DateTimeKind.Utc)), Value = 0.02m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 13, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 20, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 20, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Thursday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 3, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 3, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 6, 0, 0, DateTimeKind.Utc)), Value = 0.035m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 6, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 8, 0, 0, DateTimeKind.Utc)), Value = 0.02m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 13, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 20, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 20, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Friday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 3, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 3, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 6, 0, 0, DateTimeKind.Utc)), Value = 0.035m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 6, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 8, 0, 0, DateTimeKind.Utc)), Value = 0.02m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 13, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 20, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 20, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 7, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Saturday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 7, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 7, 18, 0, 0, DateTimeKind.Utc)), Value = 0.025m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 7, 18, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 7, 20, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 7, 20, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 8, 0, 0, 0, DateTimeKind.Utc)), Value = 0.025m }, + + // Sunday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 8, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 9, 0, 0, 0, DateTimeKind.Utc)), Value = 0.042m }, + + // Monday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 9, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 9, 3, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 9, 3, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 9, 6, 0, 0, DateTimeKind.Utc)), Value = 0.035m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 9, 6, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 9, 8, 0, 0, DateTimeKind.Utc)), Value = 0.02m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 9, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 9, 13, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 9, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 9, 20, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 9, 20, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 10, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Tuesday until 4am + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 10, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 10, 3, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 10, 3, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 10, 6, 0, 0, DateTimeKind.Utc)), Value = 0.035m } + } + }, + new object[] + { + "DayRangeWrappingAroundWeek", + "Europe/London", + new List + { + "Wed=22:00-02:00=0.05", + "Wed=02:00-22:00=0.06", + "Thu-Tue=0.04" + }, + new DateTimeOffset(new DateTime(2023, 1, 2, 2, 0, 0, DateTimeKind.Utc)), // Monday + new DateTimeOffset(new DateTime(2023, 1, 5, 4, 0, 0, DateTimeKind.Utc)), // Thursday + new List + { + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 2, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 2, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 22, 0, 0, DateTimeKind.Utc)), Value = 0.06m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 22, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 0, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + } + }, + new object[] + { + "CommaSeparatedDays", + "Europe/London", + new List + { + "Mon-Wed=08:00-13:00=0.1559", + "Mon-Wed=13:00-20:00=0.05", + "Mon-Wed=20:00-08:00=0.04", + "Thu,Fri=08:00-18:00=0.1559", + "Thu,Fri=18:00-08:00=0.04", + "Sat=04:00-08:00=0.035", + "Sat=08:00-13:00=0.02", + "Sat=13:00-04:00=0.05", + "Sun=0.22" + }, + new DateTimeOffset(new DateTime(2023, 1, 4, 2, 0, 0, DateTimeKind.Utc)), // Wed + new DateTimeOffset(new DateTime(2023, 1, 7, 4, 0, 0, DateTimeKind.Utc)), // Sat + new List + { + // Wednesday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 8, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 13, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 20, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 20, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Thursday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 8, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 5, 18, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 5, 18, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Friday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 8, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 6, 18, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 6, 18, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 7, 0, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + + // Saturday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 7, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 7, 4, 0, 0, DateTimeKind.Utc)), Value = 0.05m } + } + }, + new object[] + { + "DifferentTimeZone", + "America/New_York", + new List + { + "Mon-Fri=08:00-13:00=0.1559", + "Mon-Fri=13:00-20:00=0.05", + "Mon-Fri=20:00-03:30=0.04", + "Mon-Fri=03:30-06:00=0.035", + "Mon-Fri=06:00-08:00=0.02", + "Sat=18:00-20:00=0.05", + "Sat=20:00-18:00=0.025", + "Sun=0.042" + }, + new DateTimeOffset(new DateTime(2023, 1, 2, 2, 0, 0, DateTimeKind.Utc)), // Monday + new DateTimeOffset(new DateTime(2023, 1, 4, 4, 0, 0, DateTimeKind.Utc)), // Wednesday + new List + { + // Monday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 5, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 8, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 8, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 11, 0, 0, DateTimeKind.Utc)), Value = 0.035m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 11, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 13, 0, 0, DateTimeKind.Utc)), Value = 0.02m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 2, 18, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 2, 18, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 1, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + + // Tuesday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 1, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 5, 0, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 5, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 8, 30, 0, DateTimeKind.Utc)), Value = 0.04m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 8, 30, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 11, 0, 0, DateTimeKind.Utc)), Value = 0.035m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 11, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 13, 0, 0, DateTimeKind.Utc)), Value = 0.02m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 3, 18, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 3, 18, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 1, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + + // Wednesday + new() { ValidFrom = new DateTimeOffset(new DateTime(2023, 1, 4, 1, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2023, 1, 4, 5, 0, 0, DateTimeKind.Utc)), Value = 0.04m } + } + }, + new object[] + { + "TimeZoneDSTEdge", + "Europe/London", + new List + { + "Mon-Fri=08:00-13:00=0.1559", + "Mon-Fri=13:00-08:00=0.234", + "Sat=18:00-20:00=0.05", + "Sat=20:00-18:00=0.025", + "Sun=01:00-02:00=0.012", + "Sun=02:00-01:00=0.044" + }, + new DateTimeOffset(new DateTime(2024, 10, 25, 2, 0, 0, DateTimeKind.Utc)), // Friday before clocks go back + new DateTimeOffset(new DateTime(2024, 10, 29, 4, 0, 0, DateTimeKind.Utc)), // Tuesday after clocks go back + new List + { + // Friday + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 24, 23, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 25, 7, 0, 0, DateTimeKind.Utc)), Value = 0.234m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 25, 7, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 25, 12, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 25, 12, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 25, 23, 0, 0, DateTimeKind.Utc)), Value = 0.234m }, + + // Saturday + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 25, 23, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 26, 17, 0, 0, DateTimeKind.Utc)), Value = 0.025m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 26, 17, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 26, 19, 0, 0, DateTimeKind.Utc)), Value = 0.05m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 26, 19, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 26, 23, 0, 0, DateTimeKind.Utc)), Value = 0.025m }, + + // Sunday (clocks go back 02:00->01:00) + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 26, 23, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 27, 1, 0, 0, DateTimeKind.Utc)), Value = 0.044m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 27, 1, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 27, 2, 0, 0, DateTimeKind.Utc)), Value = 0.012m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 27, 2, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 28, 0, 0, 0, DateTimeKind.Utc)), Value = 0.044m }, + + // Monday + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 28, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 28, 8, 0, 0, DateTimeKind.Utc)), Value = 0.234m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 28, 8, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 28, 13, 0, 0, DateTimeKind.Utc)), Value = 0.1559m }, + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 28, 13, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 29, 0, 0, 0, DateTimeKind.Utc)), Value = 0.234m }, + + // Tuesday + new() { ValidFrom = new DateTimeOffset(new DateTime(2024, 10, 29, 0, 0, 0, DateTimeKind.Utc)), ValidTo = new DateTimeOffset(new DateTime(2024, 10, 29, 8, 0, 0, DateTimeKind.Utc)), Value = 0.234m } + } + } + }; + + [Test, TestCaseSource(nameof(FixedPriceWeeklyService_GetPriceData_Cases))] + public async Task FixedPriceWeeklyService_GetPriceData(string testName, string timeZone, List fixedPrices, DateTimeOffset from, DateTimeOffset to, List expectedPrices) + { + Console.WriteLine($"Running get price data test '{testName}'"); + var fixedPriceWeeklyService = Setup(timeZone, fixedPrices); + var result = await fixedPriceWeeklyService.GetPriceData(from, to); + var actualPrices = result.OrderBy(x => x.ValidFrom).ToList(); + Assert.That(actualPrices.Count(), Is.EqualTo(expectedPrices.Count)); + for (var i = 0; i < actualPrices.Count(); i++) + { + var actualPrice = actualPrices[i]; + var expectedPrice = expectedPrices[i]; + + Assert.That(actualPrice.ValidFrom, Is.EqualTo(expectedPrice.ValidFrom)); + Assert.That(actualPrice.ValidTo, Is.EqualTo(expectedPrice.ValidTo)); + Assert.That(actualPrice.Value, Is.EqualTo(expectedPrice.Value)); + } + } + + + [Test] + public void FixedPriceWeeklyService_InvalidDays() + { + var fixedPrices = new List + { + "Mon-Fri=08:00-13:00=0.1559", + "Mon-Fri=13:00-20:00=0.05", + "Mon-Fri=20:00-03:30=0.04", + "Mon-Fri=03:30-06:00=0.035", + "Mon-Fri=06:00-08:00=0.02", + "Sat=18:00-20:00=0.05", + "Sat=20:00-18:00=0.025", + "InvalidDay=0.042" + }; + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Invalid day: InvalidDay (Parameter 'day')")); + } + + [Test] + public void FixedPriceWeeklyService_IncompleteWeekDays() + { + var fixedPrices = new List + { + "Mon-Fri=08:00-13:00=0.1559", + "Mon-Fri=13:00-20:00=0.05", + "Mon-Fri=20:00-03:30=0.04", + "Mon-Fri=03:30-06:00=0.035", + "Mon-Fri=06:00-08:00=0.02", + "Sat=18:00-20:00=0.05", + "Sat=20:00-18:00=0.025" + }; + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Invalid fixed price data, does not cover the entire week")); + } + + [Test] + public void FixedPriceWeeklyService_IncompleteDayHours() + { + var fixedPrices = new List + { + "Mon-Fri=08:00-13:00=0.1559", + "Mon-Fri=13:00-20:00=0.05", + "Mon-Fri=20:00-03:30=0.04", + "Mon-Fri=03:30-06:00=0.035", + "Sat=18:00-20:00=0.05", + "Sat=20:00-18:00=0.025", + "Sun=0.042" + }; + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Invalid fixed price data, does not cover the full 24 hours")); + } + + [Test] + public void FixedPriceWeeklyService_TooManyDayHours() + { + var fixedPrices = new List + { + "Mon-Fri=08:00-13:00=0.1559", + "Mon-Fri=13:00-20:00=0.05", + "Mon-Fri=20:00-03:30=0.04", + "Mon-Fri=03:30-06:00=0.035", + "Mon-Fri=06:00-08:00=0.02", + "Sat=18:00-20:00=0.05", + "Sat=20:00-18:00=0.025", + "Sun=0.042", + "Mon-Fri=07:00-08:00=0.01", + "Mon-Fri=08:00-09:00=0.01" + }; + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Invalid fixed price data, covers more than 24 hours")); + } + + [Test] + public void FixedPriceWeeklyService_EmptyPricesList() + { + var fixedPrices = new List(); + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Invalid fixed price data, does not cover the entire week")); + } + + [Test] + public void FixedPriceWeeklyService_InvalidTimeZone() + { + var fixedPrices = new List + { + "Mon-Fri=08:00-13:00=0.1559" + }; + var exception = Assert.Throws(() => Setup("Invalid/TimeZone", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Invalid TimeZone Invalid/TimeZone (Parameter 'TimeZone')")); + } + + [Test] + public void FixedPriceWeeklyService_InvalidTimeFormat() + { + var fixedPrices = new List + { + "Mon=08:00-13:00=0.1559", + "Mon=invalid-18:00=0.05" + }; + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Failed to parse fixed price value: invalid-18:00=0.05 (Parameter 'value')")); + } + + [Test] + public void FixedPriceWeeklyService_OverlappingTimeRanges() + { + var fixedPrices = new List + { + "Mon=08:00-13:00=0.1559", + "Mon=12:00-18:00=0.05", + "Mon=18:00-07:00=0.04", + "Tue-Sun=0.04" + }; + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Invalid fixed price data, prices are not continuous")); + } + + [Test] + public void FixedPriceWeeklyService_InvalidPriceFormat() + { + var fixedPrices = new List + { + "Mon=08:00-13:00=invalid" + }; + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Failed to parse fixed price value: invalid (Parameter 'value')")); + } + + [Test] + public void FixedPriceWeeklyService_InvalidTimeValues() + { + var fixedPrices = new List + { + "Tue=08:60-09:00=0.05", + "Tue=09:00-08:60=0.04", + "Wed-Mon=0.10" + }; + var exception = Assert.Throws(() => Setup("Europe/London", fixedPrices)); + Assert.That(exception?.Message, Is.EqualTo("Invalid fromMinute: 60 (Parameter 'fromMinute')")); + } + } +} diff --git a/TeslaMateAgile/Data/Enums/EnergyProvider.cs b/TeslaMateAgile/Data/Enums/EnergyProvider.cs index 5a7c7ba..bb48577 100644 --- a/TeslaMateAgile/Data/Enums/EnergyProvider.cs +++ b/TeslaMateAgile/Data/Enums/EnergyProvider.cs @@ -5,6 +5,7 @@ public enum EnergyProvider Octopus, Tibber, FixedPrice, + FixedPriceWeekly, Awattar, Energinet, HomeAssistant, diff --git a/TeslaMateAgile/Data/Options/FixedPriceWeeklyOptions.cs b/TeslaMateAgile/Data/Options/FixedPriceWeeklyOptions.cs new file mode 100644 index 0000000..81e3193 --- /dev/null +++ b/TeslaMateAgile/Data/Options/FixedPriceWeeklyOptions.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace TeslaMateAgile.Data.Options; + +public class FixedPriceWeeklyOptions +{ + [Required] + public string TimeZone { get; set; } + + [Required] + public List Prices { get; set; } +} diff --git a/TeslaMateAgile/Program.cs b/TeslaMateAgile/Program.cs index 7835dc9..4702ab0 100644 --- a/TeslaMateAgile/Program.cs +++ b/TeslaMateAgile/Program.cs @@ -72,7 +72,7 @@ string getDatabaseVariable(string variableName) } else { - throw new ArgumentException(databasePortVariable, $"Configuration '{databasePortVariable}' is invalid, must be an integer"); + throw new ArgumentException($"Configuration '{databasePortVariable}' is invalid, must be an integer", databasePortVariable); } } @@ -125,6 +125,14 @@ string getDatabaseVariable(string variableName) .ValidateOnStart(); services.AddSingleton(); } + else if (energyProvider == EnergyProvider.FixedPriceWeekly) + { + services.AddOptions() + .Bind(config.GetSection("FixedPriceWeekly")) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddSingleton(); + } else if (energyProvider == EnergyProvider.Awattar) { services.AddOptions() diff --git a/TeslaMateAgile/Services/FixedPriceService.cs b/TeslaMateAgile/Services/FixedPriceService.cs index 5059714..910a289 100644 --- a/TeslaMateAgile/Services/FixedPriceService.cs +++ b/TeslaMateAgile/Services/FixedPriceService.cs @@ -15,11 +15,11 @@ public FixedPriceService( IOptions options ) { - _fixedPrices = GetFixedPrices(options.Value); if (!TZConvert.TryGetTimeZoneInfo(options.Value.TimeZone, out _timeZone)) { - throw new ArgumentException(nameof(options.Value.TimeZone), $"Invalid TimeZone {options.Value.TimeZone}"); + throw new ArgumentException($"Invalid TimeZone {options.Value.TimeZone}", nameof(options.Value.TimeZone)); } + _fixedPrices = GetFixedPrices(options.Value); } public Task> GetPriceData(DateTimeOffset from, DateTimeOffset to) @@ -85,7 +85,7 @@ private List GetFixedPrices(FixedPriceOptions options) foreach (var price in options.Prices.OrderBy(x => x)) { var match = FixedPriceRegex.Match(price); - if (!match.Success) { throw new ArgumentException(nameof(price), $"Failed to parse fixed price: {price}"); } + if (!match.Success) { throw new ArgumentException($"Failed to parse fixed price: {price}", nameof(price)); } var fromHour = int.Parse(match.Groups[1].Value); var fromMinute = int.Parse(match.Groups[2].Value); var toHour = int.Parse(match.Groups[3].Value); @@ -113,14 +113,14 @@ private List GetFixedPrices(FixedPriceOptions options) if (lastFixedPrice != null && (fixedPrice.FromHour != lastFixedPrice.ToHour || fixedPrice.FromMinute != lastFixedPrice.ToMinute)) { - throw new ArgumentException(nameof(price), $"Price from time does not match previous to time: {price}"); + throw new ArgumentException($"Price from time does not match previous to time: {price}", nameof(price)); } totalHours += toHours - fromHours; lastFixedPrice = fixedPrice; } if (totalHours != 24) { - throw new ArgumentException(nameof(totalHours), $"Total hours do not equal 24, currently {totalHours}"); + throw new ArgumentException($"Total hours do not equal 24, currently {totalHours}", nameof(totalHours)); } return fixedPrices; } diff --git a/TeslaMateAgile/Services/FixedPriceWeeklyService.cs b/TeslaMateAgile/Services/FixedPriceWeeklyService.cs new file mode 100644 index 0000000..f616486 --- /dev/null +++ b/TeslaMateAgile/Services/FixedPriceWeeklyService.cs @@ -0,0 +1,324 @@ +using Microsoft.Extensions.Options; +using System.Text.RegularExpressions; +using TeslaMateAgile.Data; +using TeslaMateAgile.Data.Options; +using TeslaMateAgile.Services.Interfaces; +using TimeZoneConverter; + +namespace TeslaMateAgile.Services; + +public class FixedPriceWeeklyService : IDynamicPriceDataService +{ + private readonly Dictionary> _fixedPrices; + + public FixedPriceWeeklyService( + IOptions options + ) + { + if (!TZConvert.TryGetTimeZoneInfo(options.Value.TimeZone, out _timeZone)) + { + throw new ArgumentException($"Invalid TimeZone {options.Value.TimeZone}", nameof(options.Value.TimeZone)); + } + _fixedPrices = GetFixedPrices(options.Value); + } + + public Task> GetPriceData(DateTimeOffset from, DateTimeOffset to) + { + var prices = new List(); + + // Get all days between the range inclusive + + var fromDate = from.Date; + var toDate = to.Date; + var days = new Dictionary>(); + for (var date = from.Date; date <= to.Date; date = date.AddDays(1)) + { + prices.AddRange(GetPriceDataForDate(date)); + } + + // Truncate the prices to the requested range, inclusive + + prices = prices.Where(x => x.ValidFrom < to && x.ValidTo > from).ToList(); + + return Task.FromResult((IEnumerable)prices); + } + + private List GetPriceDataForDate(DateTime date) + { + // Get all fixed prices for the day + + var dateUtc = DateTime.SpecifyKind(date, DateTimeKind.Utc); + + var prices = new List(); + + var fixedPricesForDay = _fixedPrices[date.DayOfWeek]; + + decimal? crossoverPrice = null; + foreach (var fixedPrice in fixedPricesForDay) + { + var validFrom = dateUtc; + var validTo = dateUtc; + + if (fixedPrice.FromHour.HasValue && fixedPrice.FromMinute.HasValue && fixedPrice.ToHour.HasValue && fixedPrice.ToMinute.HasValue) + { + validFrom = validFrom.AddHours(fixedPrice.FromHour.Value).AddMinutes(fixedPrice.FromMinute.Value); + validTo = validTo.AddHours(fixedPrice.ToHour.Value).AddMinutes(fixedPrice.ToMinute.Value); + } + else + { + validTo = validTo.AddDays(1); + } + + // Handle the scenario where they cross midnight + + if (validFrom > validTo) + { + validFrom = dateUtc; + crossoverPrice = fixedPrice.Value; + } + + var price = new Price + { + ValidFrom = validFrom.Add(-_timeZone.GetUtcOffset(validFrom)), + ValidTo = validTo.Add(-_timeZone.GetUtcOffset(validTo)), + Value = fixedPrice.Value + }; + prices.Add(price); + } + + // Ensure we have the last price of the day to cover the entire day + + prices = prices.OrderBy(x => x.ValidFrom).ToList(); + + if (crossoverPrice.HasValue) + { + var lastPrice = prices.Last(); + var validTo = DateTime.SpecifyKind(date.AddDays(1), DateTimeKind.Utc); + var price = new Price + { + ValidFrom = lastPrice.ValidTo, + ValidTo = validTo.Add(-_timeZone.GetUtcOffset(validTo)), + Value = crossoverPrice.Value + }; + prices.Add(price); + } + + // Verify that the prices cover the entire day + + if (prices.First().ValidFrom > dateUtc.Add(-_timeZone.GetUtcOffset(dateUtc))) + { + throw new Exception("Invalid fixed price data, does not cover the entire day"); + } + + if (prices.Last().ValidTo < dateUtc.AddDays(1).Add(-_timeZone.GetUtcOffset(dateUtc))) + { + throw new Exception("Invalid fixed price data, does not cover the entire day"); + } + + // Verify that the prices are continuous and do not overlap or have gaps + + for (var i = 1; i < prices.Count; i++) + { + if (prices[i - 1].ValidTo != prices[i].ValidFrom) + { + throw new Exception("Invalid fixed price data, prices are not continuous"); + } + } + + return prices; + } + + private class FixedPriceWeekly + { + public int? FromHour { get; set; } + public int? FromMinute { get; set; } + public int? ToHour { get; set; } + public int? ToMinute { get; set; } + public decimal Value { get; set; } + } + + private static readonly Regex FixedPriceWeeklyRegex = new Regex("(?[a-zA-Z,-]+)(=(?\\d\\d):(?\\d\\d)-(?\\d\\d):(?\\d\\d))?=(?.+)"); + private readonly TimeZoneInfo _timeZone; + + private Dictionary> GetFixedPrices(FixedPriceWeeklyOptions options) + { + var fixedPricesDict = new Dictionary>(); + + foreach (var price in options.Prices) + { + var match = FixedPriceWeeklyRegex.Match(price); + if (!match.Success) + { + throw new ArgumentException($"Failed to parse fixed price: {price}", nameof(price)); + } + + var fromHour = match.Groups["fromHour"].Success ? int.Parse(match.Groups["fromHour"].Value) : (int?)null; + var fromMinute = match.Groups["fromMinute"].Success ? int.Parse(match.Groups["fromMinute"].Value) : (int?)null; + var toHour = match.Groups["toHour"].Success ? int.Parse(match.Groups["toHour"].Value) : (int?)null; + var toMinute = match.Groups["toMinute"].Success ? int.Parse(match.Groups["toMinute"].Value) : (int?)null; + + if (!decimal.TryParse(match.Groups["value"].Value, out var value)) + { + throw new ArgumentException($"Failed to parse fixed price value: {match.Groups["value"].Value}", nameof(value)); + } + + // Validate appropriate hour and minute values + + if (fromHour.HasValue && (fromHour < 0 || fromHour > 23)) + { + throw new ArgumentException($"Invalid fromHour: {fromHour}", nameof(fromHour)); + } + + if (fromMinute.HasValue && (fromMinute < 0 || fromMinute > 59)) + { + throw new ArgumentException($"Invalid fromMinute: {fromMinute}", nameof(fromMinute)); + } + + if (toHour.HasValue && (toHour < 0 || toHour > 23)) + { + throw new ArgumentException($"Invalid toHour: {toHour}", nameof(toHour)); + } + + if (toMinute.HasValue && (toMinute < 0 || toMinute > 59)) + { + throw new ArgumentException($"Invalid toMinute: {toMinute}", nameof(toMinute)); + } + + var days = ParseDays(match.Groups["days"].Value); + + var fixedPrice = new FixedPriceWeekly + { + FromHour = fromHour, + FromMinute = fromMinute, + ToHour = toHour, + ToMinute = toMinute, + Value = value + }; + + foreach (var day in days) + { + if (!fixedPricesDict.ContainsKey(day)) + { + fixedPricesDict[day] = new List(); + } + fixedPricesDict[day].Add(fixedPrice); + } + } + + // Validate that all days of the week are covered + var allDays = Enum.GetValues(typeof(DayOfWeek)).Cast(); + if (!allDays.All(day => fixedPricesDict.ContainsKey(day))) + { + throw new ArgumentException("Invalid fixed price data, does not cover the entire week"); + } + + // Validate that each day covers the entire 24 hours + foreach (var day in fixedPricesDict.Keys) + { + // if a full day one, skip + if (fixedPricesDict[day].Any(x => !x.FromHour.HasValue || !x.FromMinute.HasValue || !x.ToHour.HasValue || !x.ToMinute.HasValue)) + { + // Verify there are no other prices for the day + if (fixedPricesDict[day].Count > 1) + { + throw new ArgumentException("Invalid fixed price data, other prices specified for full day price"); + } + continue; + } + + var dayPrices = fixedPricesDict[day].OrderBy(x => x.FromHour).ThenBy(x => x.FromMinute).ToList(); + var totalHours = 0M; + for (var i = 0; i < dayPrices.Count; i++) + { + var fromHours = dayPrices[i].FromHour.Value + (dayPrices[i].FromMinute.Value / 60M); + var toHours = dayPrices[i].ToHour.Value + (dayPrices[i].ToMinute.Value / 60M); + if (fromHours > toHours) + { + toHours += 24; + } + totalHours += toHours - fromHours; + } + if (totalHours < 24) + { + throw new ArgumentException("Invalid fixed price data, does not cover the full 24 hours"); + } + else if (totalHours > 24) + { + throw new ArgumentException("Invalid fixed price data, covers more than 24 hours"); + } + } + + // Validate that the prices are continuous and do not overlap or have gaps + foreach (var day in fixedPricesDict.Keys) + { + var dayPrices = fixedPricesDict[day].OrderBy(x => x.FromHour).ThenBy(x => x.FromMinute).ToList(); + for (var i = 1; i < dayPrices.Count; i++) + { + var previousPrice = dayPrices[i - 1]; + var currentPrice = dayPrices[i]; + if (previousPrice.ToHour != currentPrice.FromHour || previousPrice.ToMinute != currentPrice.FromMinute) + { + throw new ArgumentException("Invalid fixed price data, prices are not continuous"); + } + } + } + + return fixedPricesDict; + } + + private List ParseDays(string days) + { + var dayList = new List(); + var dayRanges = days.Split(','); + + foreach (var dayRange in dayRanges) + { + if (dayRange.Contains('-')) + { + var rangeParts = dayRange.Split('-'); + var startDay = ParseDay(rangeParts[0]); + var endDay = ParseDay(rangeParts[1]); + + if (startDay <= endDay) + { + for (var day = startDay; day <= endDay; day++) + { + dayList.Add(day); + } + } + else + { + for (var day = startDay; day <= DayOfWeek.Saturday; day++) + { + dayList.Add(day); + } + for (var day = DayOfWeek.Sunday; day <= endDay; day++) + { + dayList.Add(day); + } + } + } + else + { + dayList.Add(ParseDay(dayRange)); + } + } + + return dayList; + } + + private DayOfWeek ParseDay(string day) + { + return day.ToLower() switch + { + "mon" => DayOfWeek.Monday, + "tue" => DayOfWeek.Tuesday, + "wed" => DayOfWeek.Wednesday, + "thu" => DayOfWeek.Thursday, + "fri" => DayOfWeek.Friday, + "sat" => DayOfWeek.Saturday, + "sun" => DayOfWeek.Sunday, + _ => throw new ArgumentException($"Invalid day: {day}", nameof(day)) + }; + } +}