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); }