Skip to content

Commit

Permalink
add energy matching when possible to whole cost providers and other v…
Browse files Browse the repository at this point in the history
…arious improvements

also improved logging significantly for whole cost providers and fixed the cost value for monta
  • Loading branch information
MattJeanes committed Oct 22, 2024
1 parent 63b96df commit f134074
Show file tree
Hide file tree
Showing 9 changed files with 158 additions and 36 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ 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)
- TeslaMate__MatchingStartToleranceMinutes=30 # Tolerance in minutes for matching charge times for whole cost providers (default: 30)
- TeslaMate__MatchingEndToleranceMinutes=120 # Tolerance in minutes for matching charge times for whole cost providers (default: 30)
- TeslaMate__MatchingEnergyToleranceRatio=0.1 # Tolerance ratio for matching energy for whole cost providers that provide energy data (default: 0.1)
```
## Database connection
Expand Down
24 changes: 21 additions & 3 deletions TeslaMateAgile.Tests/IntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using GraphQL.Client.Serializer.SystemTextJson;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NUnit.Framework;
using TeslaMateAgile.Data.Options;
Expand Down Expand Up @@ -149,12 +150,29 @@ public async Task IntegrationTests_Monta()
client.BaseAddress = new Uri(baseUrl);
});

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

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 options = new TeslaMateOptions
{
MatchingStartToleranceMinutes = 120,
MatchingEndToleranceMinutes = 240,
MatchingEnergyToleranceRatio = 0.2M
};
var priceHelper = new PriceHelper(logger, null, priceDataService, Options.Create(options));

var from = DateTimeOffset.Parse("2024-10-17T05:51:55+00:00");
var to = DateTimeOffset.Parse("2024-10-17T08:57:54+00:00");
var energyUsed = 30M;

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


var possibleCharges = await priceDataService.GetCharges(from, to);
var appropriateCharge = priceHelper.LocateMostAppropriateCharge(possibleCharges, energyUsed, from, to);

Assert.That(possibleCharges, Is.Not.Empty);
}
Expand Down
53 changes: 49 additions & 4 deletions TeslaMateAgile.Tests/PriceHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ public void Setup()
.GetRequiredService<ILogger<PriceHelper>>();
_mocker.Use(logger);

var teslaMateOptions = Options.Create(new TeslaMateOptions() { MatchingToleranceMinutes = 30 });
var teslaMateOptions = Options.Create(new TeslaMateOptions()
{
MatchingStartToleranceMinutes = 30,
MatchingEndToleranceMinutes = 120,
MatchingEnergyToleranceRatio = 0.1M
});
_mocker.Use(teslaMateOptions);
}

Expand Down Expand Up @@ -158,7 +163,7 @@ public async Task PriceHelper_CalculateWholeChargeCost(string testName, List<Pro
private static readonly object[][] PriceHelper_LocateMostAppropriateCharge_Cases = new object[][] {
new object[]
{
"LocateMostAppropriateCharge",
"WithoutEnergy",
new List<ProviderCharge>
{
new ProviderCharge
Expand All @@ -182,18 +187,58 @@ public async Task PriceHelper_CalculateWholeChargeCost(string testName, List<Pro
},
DateTimeOffset.Parse("2023-08-24T23:30:00Z"),
DateTimeOffset.Parse("2023-08-25T03:00:00Z"),
30M,
10.00M
},
new object[]
{
"WithEnergy",
new List<ProviderCharge>
{
new ProviderCharge
{
Cost = 10.00M,
EnergyKwh = 25M,
StartTime = DateTimeOffset.Parse("2023-08-24T23:30:00Z"),
EndTime = DateTimeOffset.Parse("2023-08-25T03:00:00Z")
},
new ProviderCharge
{
Cost = 15.00M,
EnergyKwh = 31M,
StartTime = DateTimeOffset.Parse("2023-08-24T23:05:00Z"),
EndTime = DateTimeOffset.Parse("2023-08-25T03:35:00Z")
},
new ProviderCharge
{
Cost = 20.00M,
EnergyKwh = 25M,
StartTime = DateTimeOffset.Parse("2023-08-24T23:00:00Z"),
EndTime = DateTimeOffset.Parse("2023-08-25T03:30:00Z")
},
new ProviderCharge
{
Cost = 25.00M,
EnergyKwh = 25M,
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"),
30M,
15.00M
}
};

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

Expand Down
13 changes: 8 additions & 5 deletions TeslaMateAgile.Tests/Services/MontaServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using Moq;
using Moq.Contrib.HttpClient;
using NUnit.Framework;
using System.Net.Http.Headers;
using System.Text.Json;
using TeslaMateAgile.Data.Options;
using TeslaMateAgile.Services;
Expand Down Expand Up @@ -48,21 +47,23 @@ public async Task GetCharges_ShouldIncludeChargePointIdQueryParameter_WhenSetInM
{
startedAt = from,
stoppedAt = to,
cost = 10.0M
price = 10.0M,
consumedKwh = 15.0M
}
}
};

_handler.SetupRequest(HttpMethod.Post, "https://public-api.monta.com/api/v1/auth/token")
.ReturnsResponse(JsonSerializer.Serialize(accessTokenResponse), "application/json");

_handler.SetupRequest(HttpMethod.Get, $"https://public-api.monta.com/api/v1/charges?fromDate={from.UtcDateTime:o}&toDate={to.UtcDateTime:o}&chargePointId=123")
_handler.SetupRequest(HttpMethod.Get, $"https://public-api.monta.com/api/v1/charges?state=completed&fromDate={from.UtcDateTime:o}&toDate={to.UtcDateTime:o}&chargePointId=123")
.ReturnsResponse(JsonSerializer.Serialize(chargesResponse), "application/json");

var charges = await _subject.GetCharges(from, to);

Assert.That(charges, Is.Not.Empty);
Assert.That(charges.First().Cost, Is.EqualTo(10.0M));
Assert.That(charges.First().EnergyKwh, Is.EqualTo(15.0M));
Assert.That(charges.First().StartTime, Is.EqualTo(from));
Assert.That(charges.First().EndTime, Is.EqualTo(to));
}
Expand Down Expand Up @@ -93,21 +94,23 @@ public async Task GetCharges_ShouldNotIncludeChargePointIdQueryParameter_WhenNot
{
startedAt = from,
stoppedAt = to,
cost = 10.0M
price = 10.0M,
consumedKwh = 15.0M
}
}
};

_handler.SetupRequest(HttpMethod.Post, "https://public-api.monta.com/api/v1/auth/token")
.ReturnsResponse(JsonSerializer.Serialize(accessTokenResponse), "application/json");

_handler.SetupRequest(HttpMethod.Get, $"https://public-api.monta.com/api/v1/charges?fromDate={from.UtcDateTime:o}&toDate={to.UtcDateTime:o}")
_handler.SetupRequest(HttpMethod.Get, $"https://public-api.monta.com/api/v1/charges?state=completed&fromDate={from.UtcDateTime:o}&toDate={to.UtcDateTime:o}")
.ReturnsResponse(JsonSerializer.Serialize(chargesResponse), "application/json");

var charges = await _subject.GetCharges(from, to);

Assert.That(charges, Is.Not.Empty);
Assert.That(charges.First().Cost, Is.EqualTo(10.0M));
Assert.That(charges.First().EnergyKwh, Is.EqualTo(15.0M));
Assert.That(charges.First().StartTime, Is.EqualTo(from));
Assert.That(charges.First().EndTime, Is.EqualTo(to));
}
Expand Down
8 changes: 7 additions & 1 deletion TeslaMateAgile/Data/Options/TeslaMateOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,11 @@ public class TeslaMateOptions
public int? Phases { get; set; }

[Range(0, 240)]
public int MatchingToleranceMinutes { get; set; }
public int MatchingStartToleranceMinutes { get; set; }

[Range(0, 240)]
public int MatchingEndToleranceMinutes { get; set; }

[Range(0, 1)]
public decimal MatchingEnergyToleranceRatio { get; set; }
}
1 change: 1 addition & 0 deletions TeslaMateAgile/Data/ProviderCharge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace TeslaMateAgile.Data;
public class ProviderCharge
{
public decimal Cost { get; set; }
public decimal? EnergyKwh { get; set; }
public DateTimeOffset StartTime { get; set; }
public DateTimeOffset EndTime { get; set; }
}
75 changes: 58 additions & 17 deletions TeslaMateAgile/Helpers/PriceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ public async Task Update()
_logger.LogDebug("{ValidFrom} UTC - {ValidTo} UTC: {Value}", price.ValidFrom.UtcDateTime, price.ValidTo.UtcDateTime, price.Value);
}

var totalChargePrice = 0M;
var totalChargeEnergy = 0M;
var totalPrice = 0M;
var totalEnergy = 0M;
Charge lastCharge = null;
var chargesCalculated = 0;
var phases = ((decimal?)_teslaMateOptions.Phases) ?? DeterminePhases(charges);
Expand All @@ -139,8 +139,8 @@ public async Task Update()
chargesForPrice = chargesForPrice.OrderBy(x => x.Date).ToList();
var energyAddedInDateRange = CalculateEnergyUsed(chargesForPrice, phases.Value);
var priceForEnergy = (energyAddedInDateRange * price.Value) + (energyAddedInDateRange * _teslaMateOptions.FeePerKilowattHour);
totalChargePrice += priceForEnergy;
totalChargeEnergy += energyAddedInDateRange;
totalPrice += priceForEnergy;
totalEnergy += energyAddedInDateRange;
lastCharge = chargesForPrice.Last();
_logger.LogDebug("Calculated charge cost for {ValidFrom} UTC - {ValidTo} UTC (unit cost: {Cost}, fee per kWh: {FeePerKilowattHour}): {PriceForEnergy} for {EnergyAddedInDateRange} energy",
price.ValidFrom.UtcDateTime, price.ValidTo.UtcDateTime, price.Value, _teslaMateOptions.FeePerKilowattHour, priceForEnergy, energyAddedInDateRange);
Expand All @@ -150,38 +150,79 @@ public async Task Update()
{
throw new Exception($"Charge calculation failed, pricing calculated for {chargesCalculated} / {chargesCount}, likely missing price data");
}
return (Math.Round(totalChargePrice, 2), Math.Round(totalChargeEnergy, 2));
return (Math.Round(totalPrice, 2), Math.Round(totalEnergy, 2));
}

private async Task<(decimal Price, decimal Energy)> CalculateWholeChargeCost(IEnumerable<Charge> charges, DateTimeOffset minDate, DateTimeOffset maxDate)
{
var wholePriceDataService = _priceDataService as IWholePriceDataService;
var searchMinDate = minDate.AddMinutes(-_teslaMateOptions.MatchingToleranceMinutes);
var searchMaxDate = maxDate.AddMinutes(_teslaMateOptions.MatchingToleranceMinutes);
var searchMinDate = minDate.AddMinutes(-_teslaMateOptions.MatchingStartToleranceMinutes);
var searchMaxDate = maxDate.AddMinutes(_teslaMateOptions.MatchingEndToleranceMinutes);
_logger.LogDebug("Searching for charges between {SearchMinDate} UTC and {SearchMaxDate} UTC", searchMinDate.UtcDateTime, searchMaxDate.UtcDateTime);
var possibleCharges = await wholePriceDataService.GetCharges(searchMinDate, searchMaxDate);
var mostAppropriateCharge = LocateMostAppropriateCharge(possibleCharges, minDate, maxDate);
if (!possibleCharges.Any())
{
throw new Exception($"No possible charges found between {searchMinDate} and {searchMaxDate}");
}
_logger.LogDebug("Retrieved {Count} possible charges:", possibleCharges.Count());
foreach (var charge in possibleCharges)
{
_logger.LogDebug("{StartTime} UTC - {EndTime} UTC: {Cost}", charge.StartTime.UtcDateTime, charge.EndTime.UtcDateTime, charge.Cost);
}
var wholeChargeEnergy = CalculateEnergyUsed(charges, ((decimal?)_teslaMateOptions.Phases) ?? DeterminePhases(charges).Value);
var mostAppropriateCharge = LocateMostAppropriateCharge(possibleCharges, wholeChargeEnergy, minDate, maxDate);
return (Math.Round(mostAppropriateCharge.Cost, 2), Math.Round(wholeChargeEnergy, 2));
}

public ProviderCharge LocateMostAppropriateCharge(IEnumerable<ProviderCharge> possibleCharges, DateTimeOffset minDate, DateTimeOffset maxDate)
public ProviderCharge LocateMostAppropriateCharge(IEnumerable<ProviderCharge> possibleCharges, decimal energyUsed, DateTimeOffset minDate, DateTimeOffset maxDate)
{
var tolerance = _teslaMateOptions.MatchingToleranceMinutes;
var startToleranceMins = _teslaMateOptions.MatchingStartToleranceMinutes;
var endToleranceMins = _teslaMateOptions.MatchingEndToleranceMinutes;
var energyToleranceRatio = _teslaMateOptions.MatchingEnergyToleranceRatio;

var appropriateCharges = possibleCharges
.Where(pc => pc.StartTime >= minDate.AddMinutes(-tolerance) && pc.EndTime <= maxDate.AddMinutes(tolerance))
.OrderBy(pc => Math.Min(Math.Abs((pc.StartTime - minDate).TotalMinutes), Math.Abs((pc.EndTime - maxDate).TotalMinutes)))
.ToList();

if (!appropriateCharges.Any())
List<ProviderCharge> appropriateCharges;
if (possibleCharges.Any(x => x.EnergyKwh.HasValue))
{
_logger.LogDebug("Energy data found in possible charges, using energy and start time matching of {StartToleranceMins} minutes from {StartDate} and {EnergyToleranceRatio} ratio of {EnergyUsed}kWh energy used", startToleranceMins, minDate, energyToleranceRatio, energyUsed);
appropriateCharges = possibleCharges
.Where(x => Math.Abs((x.StartTime - minDate).TotalMinutes) <= startToleranceMins
&& Math.Abs((x.EnergyKwh.Value - energyUsed) / energyUsed) <= energyToleranceRatio)
.OrderBy(x => Math.Abs((x.StartTime - minDate).TotalMinutes))
.ToList();

if (!appropriateCharges.Any())
{
throw new Exception($"No appropriate charge found (of {possibleCharges.Count()} evaluated) within the tolerance range of {startToleranceMins} minutes of {minDate} and {energyToleranceRatio} ratio of {energyUsed}kWh energy used");
}
}
else
{
throw new Exception($"No appropriate charge found within the {tolerance} minute tolerance range.");
_logger.LogDebug("No energy data found in possible charges, using start and end time matching of {StartToleranceMins} minutes from {StartDate} and {EndToleranceMins} minutes from {EndDate}", startToleranceMins, minDate, endToleranceMins, maxDate);
appropriateCharges = possibleCharges
.Where(x => Math.Abs((x.StartTime - minDate).TotalMinutes) <= startToleranceMins
&& Math.Abs((x.EndTime - maxDate).TotalMinutes) <= endToleranceMins)
.OrderBy(x => Math.Abs((x.StartTime - minDate).TotalMinutes))
.ToList();

if (!appropriateCharges.Any())
{
throw new Exception($"No appropriate charge found (of {possibleCharges.Count()} evaluated) within the tolerance range of {startToleranceMins} minutes before {minDate} and {endToleranceMins} minutes after {maxDate}");
}
}

var mostAppropriateCharge = appropriateCharges.First();

_logger.LogInformation("Found {Count} appropriate charge(s), using the most appropriate charge from {StartTime} UTC - {EndTime} UTC with a cost of {Cost}",
if (mostAppropriateCharge.EnergyKwh.HasValue)
{
_logger.LogInformation("Found {Count} appropriate charge(s), using the most appropriate charge from {StartTime} UTC - {EndTime} UTC with a cost of {Cost} and energy of {EnergyKwh}kWh",
appropriateCharges.Count, mostAppropriateCharge.StartTime.UtcDateTime, mostAppropriateCharge.EndTime.UtcDateTime, mostAppropriateCharge.Cost, mostAppropriateCharge.EnergyKwh);
}
else
{
_logger.LogInformation("Found {Count} appropriate charge(s), using the most appropriate charge from {StartTime} UTC - {EndTime} UTC with a cost of {Cost}",
appropriateCharges.Count, mostAppropriateCharge.StartTime.UtcDateTime, mostAppropriateCharge.EndTime.UtcDateTime, mostAppropriateCharge.Cost);
}

return appropriateCharges.First();
}
Expand Down
12 changes: 8 additions & 4 deletions TeslaMateAgile/Services/MontaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public async Task<IEnumerable<ProviderCharge>> GetCharges(DateTimeOffset from, D
var charges = await GetCharges(accessToken, from, to);
return charges.Select(x => new ProviderCharge
{
Cost = x.Cost,
Cost = x.Price,
EnergyKwh = x.ConsumedKwh,
StartTime = x.StartedAt,
EndTime = x.StoppedAt
});
Expand All @@ -52,7 +53,7 @@ private async Task<string> GetAccessToken()

private async Task<Charge[]> GetCharges(string accessToken, DateTimeOffset from, DateTimeOffset to)
{
var requestUri = $"{_options.BaseUrl}/charges?fromDate={from.UtcDateTime:o}&toDate={to.UtcDateTime:o}";
var requestUri = $"{_options.BaseUrl}/charges?state=completed&fromDate={from.UtcDateTime:o}&toDate={to.UtcDateTime:o}";
if (_options.ChargePointId.HasValue)
{
requestUri += $"&chargePointId={_options.ChargePointId.Value}";
Expand Down Expand Up @@ -90,8 +91,11 @@ private class Charge
[JsonPropertyName("stoppedAt")]
public DateTimeOffset StoppedAt { get; set; }

[JsonPropertyName("cost")]
public decimal Cost { get; set; }
[JsonPropertyName("price")]
public decimal Price { get; set; }

[JsonPropertyName("consumedKwh")]
public decimal ConsumedKwh { get; set; }
}
}
}
4 changes: 3 additions & 1 deletion TeslaMateAgile/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
}
},
"TeslaMate": {
"MatchingToleranceMinutes": 30
"MatchingStartToleranceMinutes": 30,
"MatchingEndToleranceMinutes": 120,
"MatchingEnergyToleranceRatio": 0.1
},
"Octopus": {
"BaseUrl": "https://api.octopus.energy/v1",
Expand Down

0 comments on commit f134074

Please sign in to comment.