using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Costing; using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Dashboard; using MeterVault.Infrastructure.Import; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using MeterVault.Integration.Tests.Costing; using Microsoft.EntityFrameworkCore; using static MeterVault.Integration.Tests.Costing.CostSandbox; using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport; namespace MeterVault.Integration.Tests.Specialized; /// /// The Solar view's read model (brief §7.5, D-54) on the shared readers: generation from the type's generation measure /// (a virtual sum is listed, never added twice), self-consumption / feed-in / site use / autarky as far as the roles /// allow — unknown where an input has no data, never a zero — each role that nobody holds with its candidates, units from /// the meters (D-20), and savings and the feed-in credit from the cost engine (D-34 – D-38). /// /// /// The view reads every energy type with generation, so every test starts from — and leaves — an instance without meters, /// tariffs or manual costs (like ); the sandbox tests pick their own type's section. /// [Collection("Timescale")] public sealed class SolarServiceTests(TimescaleFixture fx) : IAsyncLifetime { private const int SolarErzeugung = 12; private const int NetzEinsparung = 13; public async Task InitializeAsync() { await using var db = fx.CreateContext(); await ClearDataAsync(db); } public async Task DisposeAsync() { await using var db = fx.CreateContext(); await ClearDataAsync(db); } [Fact] public async Task The_seeded_solar_view_reproduces_the_sheet() { // Generation month by month is the sheet's Solar Erzeugung; self-consumption (Haus − Netz) its Netz Einsparung; // feed-in, without a grid export meter, generation − self-consumption — the sheet's 16,481 − 8,368 = 8,113 kWh. await LoadReferenceDataAsync(); var rows = ReadRows(Electricity); var solar = await Service().GetAsync(new SolarRequest(Custom(D(1997, 1, 1), D(2026, 12, 31))) { Bucket = BucketSize.Month }); var site = Assert.Single(solar.Sites); var months = site.Quantities.Plan.Buckets; Assert.Equal("kWh", site.Unit); Assert.Equal("kWh", site.Generation!.Unit); AssertReconciles(ByMonth(months, site.Generation.Values), OracleByMonth(rows, 0, SolarErzeugung, 1), 0.5, "generation", minMatches: 20); AssertReconciles(ByMonth(months, site.SelfConsumption!.Values), OracleByMonth(rows, 0, NetzEinsparung, 1), 0.5, "self-consumption", minMatches: 20); Assert.InRange(site.Generation.Total.Value!.Value, 16481 - 1, 16481 + 1); Assert.InRange(site.SelfConsumption.Total.Value!.Value, 8368 - 1, 8368 + 1); Assert.Equal(SolarBasis.LoadMinusImport, site.SelfConsumption.Basis); Assert.Equal(SolarBasis.GenerationMinusSelfConsumption, site.FeedIn!.Basis); Assert.InRange(site.FeedIn.Total.Value!.Value, 8113 - 2, 8113 + 2); Assert.Equal(SolarBasis.Measured, site.SiteUse!.Basis); Assert.InRange(site.SiteUse.Total.Value!.Value, 51909 - 1, 51909 + 1); // Summe Solar (= Solar 1 + Solar 2) is listed as a calculated view and not added a second time. await using var db = fx.CreateContext(); var byName = await db.Meters.ToDictionaryAsync(m => m.Name, m => m.Id); Assert.Equal( [("Zähler Solar 1", true), ("Zähler Solar 2", true), ("Summe Solar", false)], site.Meters.Select(m => (m.Name, m.IsCounted))); Assert.True(site.Meters.Single(m => m.Name == "Summe Solar").IsVirtual); Assert.Equal(site.Generation.Total.Value!.Value, site.Meters.Where(m => m.IsCounted).Sum(m => m.Total.Value!.Value), 6); // Roles: the house and the grid meter hold theirs; nobody exports, and the car meter could. Assert.Equal([byName["Zähler Haus"]], site.RoleOf(MeterRole.TotalLoad).Holders.Select(h => h.MeterId)); Assert.Equal([byName["Zähler Netz"]], site.RoleOf(MeterRole.GridImport).Holders.Select(h => h.MeterId)); var export = site.RoleOf(MeterRole.GridExport); Assert.False(export.IsSet); Assert.Equal(["Zähler Auto"], export.Candidates.Select(c => c.Name)); // Savings: self-consumption at the grid meter's price, month by month — the priced history starts in September // 2022, before the first self-consumption — and no feed-in credit without an export meter. var savings = site.Savings!; Assert.Equal([byName["Zähler Netz"]], savings.MeterIds); Assert.Equal(CostStatus.Priced, savings.Total.Status); Assert.Equal(savings.Buckets.Sum(b => b.Cost ?? 0), savings.Total.Cost!.Value, 6); var january2025 = months.Select((b, i) => (b, i)).Single(p => p.b.FirstDay == D(2025, 1, 1)).i; Assert.Equal(site.SelfConsumption.Values[january2025].Value!.Value * 0.36, savings.Buckets[january2025].Cost!.Value, 6); Assert.Null(site.FeedInCredit); Assert.InRange(site.Autarky!.Value!.Value, (8368.0 / 51909 * 100) - 0.1, (8368.0 / 51909 * 100) + 0.1); } [Fact] public async Task Self_consumption_is_unknown_where_a_role_meter_has_no_data() { // A05: the house meter starts in February, so January has no self-consumption — not "0 − grid" and not "all of // the grid". Savings are priced from March, when the grid meter's price starts: February's are unknown, not free. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 100, 200, 300); var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); await box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 400, 300, 100); var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad)); await box.MonthlyReadingsAsync(house, D(2026, 2, 1), 400, 300); await box.TypePriceAsync(type, 0.30, D(2026, 3, 1)); var site = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 3, 31))); var self = site.SelfConsumption!; Assert.Equal( [(null, BucketStatus.Missing, ValueIssue.MissingSource), (100d, BucketStatus.Available, ValueIssue.None), (200d, BucketStatus.Available, ValueIssue.None)], self.Values.Select(v => (v.Value, v.Status, v.Issue))); Assert.Equal((300d, BucketStatus.Partial), (self.Total.Value!.Value, self.Total.Status)); Assert.True(self.Values[1].Provenance.HasFlag(Provenance.Derived)); // Feed-in (generation − self-consumption) has the same gap; generation itself is complete. Assert.Equal([null, 100d, 100d], site.FeedIn!.Values.Select(v => v.Value)); Assert.Equal((600d, BucketStatus.Available), (site.Generation!.Total.Value!.Value, site.Generation.Total.Status)); // Shares over the months both figures know: autarky 300 / 700, self-consumption share 300 / (200 + 300). Assert.Equal((300d / 700 * 100, BucketStatus.Partial), (site.Autarky!.Value!.Value, site.Autarky.Status)); Assert.Equal((60d, BucketStatus.Partial), (Math.Round(site.SelfConsumptionShare!.Value!.Value, 6), site.SelfConsumptionShare.Status)); // Savings: January unknown (no self-consumption), February a price gap, March 200 × 0.30. var savings = site.Savings!; Assert.Equal([null, null, 60d], savings.Buckets.Select(b => b.Cost is { } c ? Math.Round(c, 6) : (double?)null)); Assert.Equal(CostStatus.PriceGap, savings.Buckets[1].Status); Assert.Equal((60d, CostStatus.Partial), (Math.Round(savings.Total.Cost!.Value, 6), savings.Total.Status)); Assert.Contains(site.CostAttention, a => a.Kind == CostAttentionKind.MissingPrice && a.Price!.Reason == CostStatus.PriceGap); } [Fact] public async Task A_missing_role_is_named_with_the_meters_that_could_take_it() { // Only a generation meter and two ordinary meters: every role is missing, the figures that need them are absent // (not zero), and each role lists the meters whose mode may hold it — never the generation, runtime or virtual one. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var pv = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 100, 120); await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", name: $"B {Guid.NewGuid():N}"); await box.MeterAsync(type, MeterMode.DirectDelta, "kWh", name: $"A {Guid.NewGuid():N}"); await box.MeterAsync(type, MeterMode.RuntimeCounter, "h"); await box.VirtualAsync(type, $"m{pv}", QuantityKind.Generation, "kWh", MeterVault.Core.Analysis.Virtual.VirtualCostRule.None); var site = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28))); Assert.Equal(220, site.Generation!.Total.Value!.Value, 6); Assert.Null(site.SelfConsumption); Assert.Null(site.FeedIn); Assert.Null(site.SiteUse); Assert.Null(site.Autarky); Assert.Null(site.Savings); Assert.Null(site.FeedInCredit); Assert.Equal(MeterRoleRules.All, site.Roles.Select(r => r.Role)); Assert.All(site.Roles, role => { Assert.False(role.IsSet); Assert.Equal(2, role.Candidates.Count); Assert.StartsWith("A ", role.Candidates[0].Name, StringComparison.Ordinal); Assert.StartsWith("B ", role.Candidates[1].Name, StringComparison.Ordinal); }); } [Fact] public async Task A_grid_export_meter_gives_measured_feed_in_and_its_credit() { // Generation 500 a month, export 200, import 100, no house meter: self-consumption is generation − export (300), // site use self-consumption + import (400), autarky 75 %. The credit is the bill's feed-in line: 200 × 0.08. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 500, 500); var export = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport)); await box.MonthlyReadingsAsync(export, D(2026, 1, 1), 200, 200); var import = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); await box.MonthlyReadingsAsync(import, D(2026, 1, 1), 100, 100); await box.TypePriceAsync(type, 0.30, D(2025, 1, 1)); await box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2025, 1, 1)); var site = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28))); Assert.Equal((SolarBasis.GenerationMinusExport, 600d), (site.SelfConsumption!.Basis, site.SelfConsumption.Total.Value!.Value)); Assert.Equal((SolarBasis.Measured, 400d), (site.FeedIn!.Basis, site.FeedIn.Total.Value!.Value)); Assert.Equal([export], site.FeedIn.MeterIds); Assert.Equal((SolarBasis.SelfConsumptionPlusImport, 800d), (site.SiteUse!.Basis, site.SiteUse.Total.Value!.Value)); Assert.Equal(75, site.Autarky!.Value!.Value, 6); Assert.Equal(60, site.SelfConsumptionShare!.Value!.Value, 6); Assert.Equal(600 * 0.30, site.Savings!.Total.Cost!.Value, 6); Assert.Equal([import], site.Savings.MeterIds); Assert.Equal((32d, CostStatus.Priced), (Math.Round(site.FeedInCredit!.Total.FeedInCredit!.Value, 6), site.FeedInCredit.Total.Status)); Assert.Equal([16d, 16d], site.FeedInCredit.Buckets.Select(b => Math.Round(b.FeedInCredit!.Value, 6))); Assert.True(site.RoleOf(MeterRole.GridExport).IsSet); Assert.False(site.RoleOf(MeterRole.TotalLoad).IsSet); } [Fact] public async Task Units_come_from_the_meters_and_are_never_combined() { // D-20: a generation counter in Wh reads in Wh — nothing assumes kWh. A second one in kWh is its own measure, not // added; and an export meter in kWh cannot be subtracted from generation in Wh. await using var box = new CostSandbox(fx); var type = await box.TypeAsync("Wh"); var small = await box.MeterAsync(type, MeterMode.GenerationCounter, "Wh", D(2026, 1, 1)); await box.MonthlyReadingsAsync(small, D(2026, 1, 1), 5000, 7000); var single = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28))); Assert.Equal(("Wh", "Wh", 12000d), (single.Unit, single.Generation!.Unit, single.Generation.Total.Value!.Value)); Assert.Empty(single.OtherGeneration); var big = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", D(2026, 1, 1)); await box.MonthlyReadingsAsync(big, D(2026, 1, 1), 3, 4); var export = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport)); await box.MonthlyReadingsAsync(export, D(2026, 1, 1), 1, 1); var mixed = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28))); // The export meter's unit picks the generation measure in kWh; the one in Wh is reported apart. Assert.Equal(("kWh", 7d), (mixed.Generation!.Unit, mixed.Generation.Total.Value!.Value)); var other = Assert.Single(mixed.OtherGeneration); Assert.Equal(("Wh", 12000d), (other.Unit, other.Total.Value!.Value)); Assert.Equal(5, mixed.SelfConsumption!.Total.Value!.Value, 6); // An export in another unit than generation: self-consumption cannot be calculated, and says which units clash. await using (var db = fx.CreateContext()) { await db.Readings.Where(r => r.MeterId == big).ExecuteDeleteAsync(); await db.Consumption.Where(c => c.MeterId == big).ExecuteDeleteAsync(); await db.Meters.Where(m => m.Id == big).ExecuteDeleteAsync(); } var clash = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28))); Assert.Equal("Wh", clash.Generation!.Unit); Assert.True(clash.SelfConsumption!.UnitsDiffer); Assert.Equal(["Wh", "kWh"], clash.SelfConsumption.InputUnits); Assert.All(clash.SelfConsumption.Values, v => Assert.Equal((null, BucketStatus.Invalid), (v.Value, v.Status))); Assert.Equal(BucketStatus.Invalid, clash.SelfConsumptionShare!.Status); Assert.Null(clash.Savings); } [Fact] public async Task Without_a_generation_meter_there_is_no_section() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100); var solar = await Service().GetAsync(new SolarRequest(Custom(D(2026, 1, 1), D(2026, 1, 31)))); Assert.Empty(solar.Sites); Assert.Null(solar.Plan); Assert.Null(await Service().GetAvailabilityAsync(Now)); } [Fact] public async Task A_period_without_data_is_missing_not_zero_and_the_availability_says_where_data_is() { // The seeded case of the brief: data until May 2026, today 19 September — month to date has nothing. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 3, 1), 100, 120, 130); var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 3, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); await box.MonthlyReadingsAsync(grid, D(2026, 3, 1), 50, 50, 50); var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 3, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad)); await box.MonthlyReadingsAsync(house, D(2026, 3, 1), 90, 90, 90); var site = await SiteAsync(type, Preset(PeriodPreset.MonthToDate)); Assert.Equal(BucketStatus.Missing, site.Generation!.Total.Status); Assert.Null(site.Generation.Total.Value); Assert.Equal(BucketStatus.Missing, site.SelfConsumption!.Total.Status); Assert.Null(site.SelfConsumption.Total.Value); Assert.Null(site.Autarky!.Value); Assert.Null(site.Savings?.Total.Cost); Assert.Equal(D(2026, 5, 31), site.Availability!.LastDay); Assert.Equal(D(2026, 5, 31), (await Service().GetAvailabilityAsync(Now))!.LastDay); } private async Task SiteAsync(short type, ResolvedPeriod period) { var solar = await Service().GetAsync(new SolarRequest(period) { Bucket = BucketSize.Month }); return solar.Sites.Single(s => s.EnergyTypeId == type); } private SolarService Service() { var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" }); var reader = new AnalysisReader(fx, options); return new SolarService(fx, reader, new CostReader(fx, reader, options)); } private static Dictionary ByMonth(IReadOnlyList buckets, IReadOnlyList values) => buckets.Select((b, i) => (b.FirstDay, values[i].Value)) .Where(p => p.Value is not null) .ToDictionary(p => p.FirstDay, p => p.Value!.Value); private async Task LoadReferenceDataAsync() { await using var db = fx.CreateContext(); var importer = new ReferenceDataImporter(db, new ImportService(db, Normalization(db)), new CsvImporter()); await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures")); } internal static async Task ClearDataAsync(MeterVaultDbContext db) { await db.MeterLinks.ExecuteDeleteAsync(); await db.Consumption.ExecuteDeleteAsync(); await db.Readings.ExecuteDeleteAsync(); await db.MeterEvents.ExecuteDeleteAsync(); await db.ManualCosts.ExecuteDeleteAsync(); await db.CostCategoryMembers.ExecuteDeleteAsync(); await db.Tariffs.ExecuteDeleteAsync(); await db.Tanks.ExecuteDeleteAsync(); await db.MeterSources.ExecuteDeleteAsync(); await db.Meters.ExecuteDeleteAsync(); await db.ImportBatches.ExecuteDeleteAsync(); } }