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