using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Import;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Reconciliation;
///
/// Reconciles the five electricity meters and a derived virtual meter against the Strom sheet's
/// own Verbrauch / Netz Einsparung columns (SDD §2.2). Runs without a database.
///
public sealed class ElectricityReconciliationTests
{
/// The virtual Netz Einsparung meter; any id no reference profile uses.
private const int NetzEinsparung = 100;
private static MeterConfig Cumulative(int id) =>
new() { MeterId = id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
private static MeterConfig Generation(int id) =>
new() { MeterId = id, Mode = MeterMode.GenerationCounter, Unit = "kWh" };
[Theory]
[InlineData(ReferenceProfiles.Haus, 7)] // Haus Verbrauch
[InlineData(ReferenceProfiles.Netz, 8)] // Netz Verbrauch
[InlineData(ReferenceProfiles.Auto, 9)] // Auto Verbrauch
[InlineData(ReferenceProfiles.Solar1, 10)] // Solar Erzeugung 1
[InlineData(ReferenceProfiles.Solar2, 11)] // Solar Erzeugung 2
public void Meter_consumption_matches_the_sheet(int meterId, int oracleColumn)
{
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
var config = meterId is ReferenceProfiles.Solar1 or ReferenceProfiles.Solar2
? Generation(meterId)
: Cumulative(meterId);
var computed = ByMonth(Normalize(staged, config));
var oracle = OracleByMonth(ReadRows(Electricity), dateColumn: 0, valueColumn: oracleColumn, firstDataRow: 1);
AssertReconciles(computed, oracle, tolerance: 1.0, $"meter {meterId}", minMatches: 20);
}
[Fact]
public void Netz_einsparung_virtual_matches_the_sheet()
{
// Evaluated the way the app reads a virtual meter (D-27, D-29): per month bucket, strict about coverage.
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
var haus = Normalize(staged, Cumulative(ReferenceProfiles.Haus));
var netz = Normalize(staged, Cumulative(ReferenceProfiles.Netz));
var months = MonthBuckets(haus.Concat(netz));
var evaluation = VirtualEvaluator.Evaluate(
NetzEinsparung,
Formula.Parse($"m{ReferenceProfiles.Haus} - m{ReferenceProfiles.Netz}"),
QuantityKind.Consumption,
months,
[MonthlySource(ReferenceProfiles.Haus, haus), MonthlySource(ReferenceProfiles.Netz, netz)]);
var computed = months.Zip(evaluation.Values)
.Where(m => m.Second.Status == BucketStatus.Available)
.ToDictionary(m => m.First.FirstDay, m => m.Second.Value!.Value);
var oracle = OracleByMonth(ReadRows(Electricity), dateColumn: 0, valueColumn: 13, firstDataRow: 1); // Netz Einsparung
AssertReconciles(computed, oracle, tolerance: 1.0, "Netz Einsparung", minMatches: 20);
Assert.True(evaluation.IsAdditive);
}
/// Contiguous UTC month buckets from the first to the last month any row falls in.
private static List MonthBuckets(IEnumerable rows)
{
var keys = rows.Select(r => MonthKey(r.Time)).ToList();
var buckets = new List();
for (var month = keys.Min(); month <= keys.Max(); month = month.AddMonths(1))
{
buckets.Add(new AnalysisBucket(month, month.AddMonths(1), Utc(month), Utc(month.AddMonths(1)), BucketSize.Month));
}
return buckets;
}
///
/// A meter's normalized rows as the reader hands them to the evaluator: day totals, with every day of a month
/// that has a row covered at month resolution — an imported monthly table covers its labelled months (D-10), and
/// each interval lies inside its month, so the run is divided at months (A-02).
///
private static VirtualSource MonthlySource(int meterId, IReadOnlyList rows)
{
var days = new Dictionary();
foreach (var month in rows.Select(r => MonthKey(r.Time)).Distinct())
{
for (var day = month; day < month.AddMonths(1); day = day.AddDays(1))
{
days[day] = new SourceDay(0, true, ResolutionClass.Month, Provenance.Imported, DividedAtMonths: true);
}
}
foreach (var row in rows)
{
var day = DateOnly.FromDateTime(row.Time.UtcDateTime);
days[day] = days[day] with { Amount = days[day].Amount + row.Amount };
}
return new VirtualSource(meterId, days);
}
private static DateTimeOffset Utc(DateOnly day) => new(day.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
}