Skip to content

Commit

Permalink
Merge pull request #65 from MattJeanes/matt/monta
Browse files Browse the repository at this point in the history
  • Loading branch information
MattJeanes authored Oct 21, 2024
2 parents d88f099 + f993789 commit d5bb0e2
Show file tree
Hide file tree
Showing 24 changed files with 534 additions and 51 deletions.
6 changes: 6 additions & 0 deletions .devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"tasks": {
"test": "dotnet test",
"build": "dotnet build"
}
}
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore

# User-specific files
*.rsuser
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Supported energy providers / tarriffs:
- [aWATTar](https://www.awattar.de/)
- [Energinet](https://www.energidataservice.dk/tso-electricity/Elspotprices)
- [Home Assistant](https://www.home-assistant.io/)
- [Monta](https://monta.com/)

## How to use
You can either use it in a Docker container or go to the releases and download the zip of the latest one and run it on the command line using `./TeslaMateAgile`.
Expand Down Expand Up @@ -101,6 +102,15 @@ See below for how to configure the environment variables appropriately
- HomeAssistant__EntityId=input_number.energy_price # ID of the number-based entity containing price data in Home Assistant (Cost is in your currency e.g. pounds, euros, dollars (not pennies, cents, etc))
```
### Monta
```yaml
- TeslaMate__EnergyProvider=Monta
- Monta__ClientId=abc123 # Client ID of your Monta Public API app
- Monta__ClientSecret=abc123 # Client secret of your Monta Publiic API app
- Monta__ChargePointId=123 # Optional: Restrict searches to a particular charge point ID
```
## Optional environment variables
```yaml
- Logging__LogLevel__Default=Debug # Enables debug logging, useful for seeing exactly how a charge was calculated
Expand All @@ -109,6 +119,7 @@ See below for how to configure the environment variables appropriately
- TeslaMate__FeePerKilowattHour=0.25 # Adds a flat fee per kWh, useful for certain arrangements (default: 0)
- TeslaMate__LookbackDays=7 # Only calculate charges started in the last x days (default: null, all charges)
- TeslaMate__Phases=1 # Number of phases your charger is connected to (default: null, auto-detect)
- TeslaMate__MatchingToleranceMinutes=30 # Tolerance in minutes for matching charge times for whole cost providers (default: 30)
```
## Database connection
Expand Down Expand Up @@ -147,7 +158,7 @@ Or if you're familar with curl / postman / etc
#### Tibber Access Token
Tibber requires users to supply their access token to provide pricing information for their tarriff. It is only used to query tarriff information and at no point does TeslaMateAgile request or access any data related to consumption or any account details. You can find the related code [here](https://github.com/MattJeanes/TeslaMateAgile/blob/master/TeslaMateAgile/Services/TibberService.cs).
Tibber requires users to supply their access token to provide pricing information for their tarriff. It is only used to query tarriff information and at no point does TeslaMateAgile request or access any data related to consumption or any account details. You can find the related code [here](https://github.com/MattJeanes/TeslaMateAgile/blob/main/TeslaMateAgile/Services/TibberService.cs).
You can acquire this token here: https://developer.tibber.com/settings/accesstoken
Expand Down Expand Up @@ -199,6 +210,11 @@ This is the ID of the number-based entity containing price data in Home Assistan
#### Lookback Days
Home Assistant by default only keeps 10 days of history and will fail to calculate charges if the data is missing. It is highly recommended to set this to a value lower than the number of days of history you have in Home Assistant. A good value is 7 days if you have the default 10 days of history.

### Monta

#### Client ID and Secret
Monta requires users to supply their Monta public API client ID and secret to request charging information. It is only used to query charging information and at no point does TeslaMateAgile request or access any data related to anything else. You can find the related code [here](https://github.com/MattJeanes/TeslaMateAgile/blob/main/TeslaMateAgile/Services/MontaService.cs).

## FAQ

### How do I recalculate a charge?
Expand Down
45 changes: 39 additions & 6 deletions TeslaMateAgile.Tests/IntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ public async Task IntegrationTests_Tibber()
services.AddHttpClient();
services.AddTransient<IGraphQLJsonSerializer, SystemTextJsonSerializer>();
services.Configure<TibberOptions>(config.GetSection("Tibber"));
services.AddHttpClient<IPriceDataService, TibberService>((serviceProvider, client) =>
services.AddHttpClient<IDynamicPriceDataService, TibberService>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<TibberOptions>>().Value;
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.AccessToken);
});

var priceDataService = services.BuildServiceProvider().GetRequiredService<IPriceDataService>();
var priceDataService = services.BuildServiceProvider().GetRequiredService<IDynamicPriceDataService>();

var from = DateTimeOffset.Parse("2020-01-01T00:25:00+00:00");
var to = DateTimeOffset.Parse("2020-01-01T15:00:00+00:00");
Expand All @@ -60,15 +60,15 @@ public async Task IntegrationTests_Awattar()
services.AddOptions<AwattarOptions>()
.Bind(config.GetSection("Awattar"))
.ValidateDataAnnotations();
services.AddHttpClient<IPriceDataService, AwattarService>((serviceProvider, client) =>
services.AddHttpClient<IDynamicPriceDataService, AwattarService>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<AwattarOptions>>().Value;
var baseUrl = options.BaseUrl;
if (!baseUrl.EndsWith("/")) { baseUrl += "/"; }
client.BaseAddress = new Uri(baseUrl);
});

var priceDataService = services.BuildServiceProvider().GetRequiredService<IPriceDataService>();
var priceDataService = services.BuildServiceProvider().GetRequiredService<IDynamicPriceDataService>();

var from = DateTimeOffset.Parse("2020-01-01T00:25:00+00:00");
var to = DateTimeOffset.Parse("2020-01-01T15:55:00+00:00");
Expand Down Expand Up @@ -103,14 +103,14 @@ public async Task IntegrationTests_Energinet()
services.AddOptions<EnerginetOptions>()
.Bind(config.GetSection("Energinet"))
.ValidateDataAnnotations();
services.AddHttpClient<IPriceDataService, EnerginetService>((serviceProvider, client) =>
services.AddHttpClient<IDynamicPriceDataService, EnerginetService>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<EnerginetOptions>>().Value;
var baseUrl = options.BaseUrl;
if (!baseUrl.EndsWith("/")) { baseUrl += "/"; }
client.BaseAddress = new Uri(baseUrl);
});
var priceDataService = services.BuildServiceProvider().GetRequiredService<IPriceDataService>();
var priceDataService = services.BuildServiceProvider().GetRequiredService<IDynamicPriceDataService>();

var from = new DateTimeOffset(2022, 2, 20, 0, 0, 0, new TimeSpan(1, 0, 0));
var to = new DateTimeOffset(2022, 2, 20, 23, 59, 0, new TimeSpan(1, 0, 0));
Expand All @@ -125,4 +125,37 @@ public async Task IntegrationTests_Energinet()
Assert.That(priceData.Min(x => x.ValidFrom), Is.LessThanOrEqualTo(from));
Assert.That(priceData.Max(x => x.ValidTo), Is.GreaterThanOrEqualTo(to));
}

[Ignore(IntegrationTest)]
[Test]
public async Task IntegrationTests_Monta()
{
var configBuilder = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddUserSecrets<Program>();

var config = configBuilder.Build();

var services = new ServiceCollection();
services.AddHttpClient();
services.AddOptions<MontaOptions>()
.Bind(config.GetSection("Monta"))
.ValidateDataAnnotations();
services.AddHttpClient<IWholePriceDataService, MontaService>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<MontaOptions>>().Value;
var baseUrl = options.BaseUrl;
if (!baseUrl.EndsWith("/")) { baseUrl += "/"; }
client.BaseAddress = new Uri(baseUrl);
});

var priceDataService = services.BuildServiceProvider().GetRequiredService<IWholePriceDataService>();

var from = DateTimeOffset.Parse("2024-10-17T00:00:00+00:00");
var to = DateTimeOffset.Parse("2024-10-17T15:00:00+00:00");

var possibleCharges = await priceDataService.GetCharges(from, to);

Assert.That(possibleCharges, Is.Not.Empty);
}
}
139 changes: 122 additions & 17 deletions TeslaMateAgile.Tests/PriceHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.AutoMock;
using NUnit.Framework;
using TeslaMateAgile.Data;
using TeslaMateAgile.Data.Options;
Expand All @@ -14,25 +15,25 @@ namespace TeslaMateAgile.Tests;

public class PriceHelperTests
{
public PriceHelper Setup(List<Price> prices = null)
{
if (prices == null) { prices = new List<Price>(); }
private AutoMocker _mocker;
private PriceHelper _subject;

var priceDataService = new Mock<IPriceDataService>();
priceDataService
.Setup(x => x.GetPriceData(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>()))
.ReturnsAsync(prices.OrderBy(x => x.ValidFrom));
[SetUp]
public void Setup()
{
_mocker = new AutoMocker();

var teslaMateDbContext = new Mock<TeslaMateDbContext>(new DbContextOptions<TeslaMateDbContext>());
_mocker.Use(teslaMateDbContext);

var logger = new ServiceCollection()
.AddLogging(x => x.AddConsole().SetMinimumLevel(LogLevel.Debug))
.BuildServiceProvider()
.GetRequiredService<ILogger<PriceHelper>>();
_mocker.Use(logger);

var teslaMateOptions = Options.Create(new TeslaMateOptions());

return new PriceHelper(logger, teslaMateDbContext.Object, priceDataService.Object, teslaMateOptions);
var teslaMateOptions = Options.Create(new TeslaMateOptions() { MatchingToleranceMinutes = 30 });
_mocker.Use(teslaMateOptions);
}

private static readonly object[][] PriceHelper_CalculateChargeCost_Cases = new object[][] {
Expand Down Expand Up @@ -83,8 +84,9 @@ public PriceHelper Setup(List<Price> prices = null)
public async Task PriceHelper_CalculateChargeCost(string testName, List<Price> prices, List<Charge> charges, decimal expectedPrice, decimal expectedEnergy)
{
Console.WriteLine($"Running calculate charge cost test '{testName}'");
var priceHelper = Setup(prices);
var (price, energy) = await priceHelper.CalculateChargeCost(charges);
SetupDynamicPriceDataService(prices);
_subject = _mocker.CreateInstance<PriceHelper>();
var (price, energy) = await _subject.CalculateChargeCost(charges);
Assert.That(expectedPrice, Is.EqualTo(price));
Assert.That(expectedEnergy, Is.EqualTo(energy));
}
Expand All @@ -103,20 +105,123 @@ public async Task PriceHelper_CalculateChargeCost(string testName, List<Price> p
public void PriceHelper_CalculateEnergyUsed(string testName, List<Charge> charges, decimal expectedEnergy)
{
Console.WriteLine($"Running calculate energy used test '{testName}'");
var priceHelper = Setup();
var phases = priceHelper.DeterminePhases(charges);
SetupDynamicPriceDataService();
_subject = _mocker.CreateInstance<PriceHelper>();
var phases = _subject.DeterminePhases(charges);
if (!phases.HasValue) { throw new Exception("Phases has no value"); }
var energy = priceHelper.CalculateEnergyUsed(charges, phases.Value);
var energy = _subject.CalculateEnergyUsed(charges, phases.Value);
Assert.That(expectedEnergy, Is.EqualTo(Math.Round(energy, 2)));
}

[Test]
public async Task PriceHelper_NoPhaseData()
{
var charges = TestHelpers.ImportCharges("nophasedata_test.csv");
var priceHelper = Setup();
var (price, energy) = await priceHelper.CalculateChargeCost(charges);
SetupDynamicPriceDataService();
_subject = _mocker.CreateInstance<PriceHelper>();
var (price, energy) = await _subject.CalculateChargeCost(charges);
Assert.That(0, Is.EqualTo(price));
Assert.That(0, Is.EqualTo(energy));
}

private static readonly object[][] PriceHelper_CalculateWholeChargeCost_Cases = new object[][] {
new object[]
{
"WholeCharge",
new List<ProviderCharge>
{
new ProviderCharge
{
Cost = 10.00M,
StartTime = DateTimeOffset.Parse("2023-08-24T23:30:00Z"),
EndTime = DateTimeOffset.Parse("2023-08-25T03:00:00Z")
}
},
TestHelpers.ImportCharges("exactmillisecond_test.csv"),
10.00M,
21.41M,
}
};

[Test]
[TestCaseSource(nameof(PriceHelper_CalculateWholeChargeCost_Cases))]
public async Task PriceHelper_CalculateWholeChargeCost(string testName, List<ProviderCharge> providerCharges, List<Charge> charges, decimal expectedPrice, decimal expectedEnergy)
{
Console.WriteLine($"Running calculate whole charge cost test '{testName}'");
SetupWholePriceDataService(providerCharges);
_subject = _mocker.CreateInstance<PriceHelper>();
var (price, energy) = await _subject.CalculateChargeCost(charges);
Assert.That(expectedPrice, Is.EqualTo(price));
Assert.That(expectedEnergy, Is.EqualTo(energy));
}

private static readonly object[][] PriceHelper_LocateMostAppropriateCharge_Cases = new object[][] {
new object[]
{
"LocateMostAppropriateCharge",
new List<ProviderCharge>
{
new ProviderCharge
{
Cost = 10.00M,
StartTime = DateTimeOffset.Parse("2023-08-24T23:30:00Z"),
EndTime = DateTimeOffset.Parse("2023-08-25T03:00:00Z")
},
new ProviderCharge
{
Cost = 15.00M,
StartTime = DateTimeOffset.Parse("2023-08-24T23:00:00Z"),
EndTime = DateTimeOffset.Parse("2023-08-25T03:30:00Z")
},
new ProviderCharge
{
Cost = 20.00M,
StartTime = DateTimeOffset.Parse("2023-08-24T22:30:00Z"),
EndTime = DateTimeOffset.Parse("2023-08-25T04:00:00Z")
}
},
DateTimeOffset.Parse("2023-08-24T23:30:00Z"),
DateTimeOffset.Parse("2023-08-25T03:00:00Z"),
10.00M
}
};

[Test]
[TestCaseSource(nameof(PriceHelper_LocateMostAppropriateCharge_Cases))]
public void PriceHelper_LocateMostAppropriateCharge(string testName, List<ProviderCharge> providerCharges, DateTimeOffset minDate, DateTimeOffset maxDate, decimal expectedCost)
{
Console.WriteLine($"Running locate most appropriate charge test '{testName}'");
SetupWholePriceDataService(providerCharges);
_subject = _mocker.CreateInstance<PriceHelper>();
var mostAppropriateCharge = _subject.LocateMostAppropriateCharge(providerCharges, minDate, maxDate);
Assert.That(expectedCost, Is.EqualTo(mostAppropriateCharge.Cost));
}

private void SetupDynamicPriceDataService(List<Price> prices = null)
{
if (prices == null) { prices = new List<Price>(); }

var priceDataService = new Mock<IPriceDataService>();

priceDataService
.As<IDynamicPriceDataService>()
.Setup(x => x.GetPriceData(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>()))
.ReturnsAsync(prices.OrderBy(x => x.ValidFrom));

_mocker.Use(priceDataService.Object);
}

private void SetupWholePriceDataService(List<ProviderCharge> providerCharges = null)
{
if (providerCharges == null) { providerCharges = new List<ProviderCharge>(); }

var priceDataService = new Mock<IPriceDataService>();

priceDataService
.As<IWholePriceDataService>()
.Setup(x => x.GetCharges(It.IsAny<DateTimeOffset>(), It.IsAny<DateTimeOffset>()))
.ReturnsAsync(providerCharges);

_mocker.Use(priceDataService.Object);
}
}
Loading

0 comments on commit d5bb0e2

Please sign in to comment.