using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Dashboard; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Integration.Tests; /// /// The per-energy-type flow graph (Sankey) on the shared analysis reader (D-30): node values are the meters' canonical /// period totals — a virtual meter's through its formula; a single-parent chain attributes the child's full total to /// its parent and shows the remainder as "Other"; a two-parent merge splits the child proportionally (an estimate); /// a pure-sum virtual meter is fed by its calculation dependencies; any other virtual meter, and a meter in another /// unit, is only in the table; links never carry more than the parent measured; a meter without data is never a zero. /// /// /// Every meter here is installed on 1 January 2024 and read once, at the first instant of 2025 (UTC, the zone of a /// service built without options), so its whole amount accrues over 2024 and the year's total is fully covered. /// Each test creates its own energy type and meters and removes them again. /// [Collection("Timescale")] public sealed class FlowServiceTests(TimescaleFixture fx) : IAsyncLifetime { private static readonly DateOnly Year = new(2024, 1, 1); private static readonly DateOnly NextYear = new(2025, 1, 1); private readonly List _meters = []; private readonly List _types = []; public Task InitializeAsync() => Task.CompletedTask; public async Task DisposeAsync() { await using var db = fx.CreateContext(); var ids = _meters.ToArray(); await db.Consumption.Where(c => ids.Contains(c.MeterId)).ExecuteDeleteAsync(); await db.Readings.Where(r => ids.Contains(r.MeterId)).ExecuteDeleteAsync(); await db.Meters.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync(); var types = _types.ToArray(); await db.EnergyTypes.Where(t => types.Contains(t.Id)).ExecuteDeleteAsync(); } [Fact] public async Task Single_parent_chain_makes_other_remainder() { var type = await TypeAsync(); var main = await MeterAsync(type, "Main", 100); var car = await MeterAsync(type, "Car", 30); await LinkAsync(main, car); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); Assert.Equal(100, graph.Total, 1); var link = Assert.Single(graph.Links, l => l.To == $"m{car}"); Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent Assert.False(link.IsEstimated); Assert.False(link.IsCalculated); var other = Assert.Single(graph.Nodes, n => n.IsOther); Assert.Equal(70, other.Value, 1); // 100 − 30 Assert.Equal(BucketStatus.Available, other.Status); Assert.Equal(BucketStatus.Available, graph.Nodes.Single(n => n.MeterId == main).Status); } [Fact] public async Task Two_parents_split_child_proportionally() { var type = await TypeAsync(); var grid = await MeterAsync(type, "Grid", 75); var solar = await MeterAsync(type, "Solar draw", 25); var house = await MeterAsync(type, "House", 40); await LinkAsync(grid, house); await LinkAsync(solar, house); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); // House (40) splits 75:25 → 30 from grid, 10 from solar — an estimate, and marked so. var fromGrid = graph.Links.Single(l => l.From == $"m{grid}" && l.To == $"m{house}"); var fromSolar = graph.Links.Single(l => l.From == $"m{solar}" && l.To == $"m{house}"); Assert.Equal(30, fromGrid.Value, 1); Assert.Equal(10, fromSolar.Value, 1); Assert.True(fromGrid.IsEstimated); Assert.True(fromSolar.IsEstimated); Assert.False(fromGrid.IsCapped); } [Fact] public async Task Generation_meter_counts_as_source() { var type = await TypeAsync(); var grid = await MeterAsync(type, "Grid", 75); // grid import var solar = await MeterAsync(type, "Solar", 30, MeterMode.GenerationCounter); // solar generation var house = await MeterAsync(type, "House", 40); // house load await LinkAsync(grid, house); await LinkAsync(solar, house); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); // Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar. Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid}" && l.To == $"m{house}").Value, 1); Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar}" && l.To == $"m{house}").Value, 1); // Remainder across grid+solar = (75+30) − 40 = 65 (export + battery/inverter losses). Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1); Assert.Equal(QuantityKind.Generation, graph.MeterFor(solar)!.Kind); } [Fact] public async Task Virtual_sum_meter_is_its_formula() { var type = await TypeAsync(); var solar1 = await MeterAsync(type, "Solar 1", 15, MeterMode.GenerationCounter); var solar2 = await MeterAsync(type, "Solar 2", 15, MeterMode.GenerationCounter); var solar3 = await MeterAsync(type, "Solar 3", 5, MeterMode.GenerationCounter); var sumSolar = await VirtualAsync(type, "Sum Solar", $"m{solar1} + m{solar2}", QuantityKind.Generation, VirtualCostRule.SourceCosts); var grid = await MeterAsync(type, "Grid", 75); var house = await MeterAsync(type, "House", 40); // Solar1 + Solar2 → Sum Solar; Grid + Sum Solar → House. Solar 3 is linked into the sum too, but the formula — // not a link — says what the sum is (D-25). await LinkAsync(solar1, sumSolar); await LinkAsync(solar2, sumSolar); await LinkAsync(solar3, sumSolar); await LinkAsync(grid, house); await LinkAsync(sumSolar, house); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); // Sum Solar has no readings but is Solar 1 + Solar 2 = 30, by its formula. var node = graph.Nodes.Single(n => n.MeterId == sumSolar); Assert.Equal(30, node.Value, 1); Assert.True(node.IsVirtual); Assert.Equal(SeriesBasis.Virtual, graph.MeterFor(sumSolar)!.Basis); // Its incoming edges are its calculation dependencies, each at the source's value, marked calculated. var incoming = graph.Links.Where(l => l.To == $"m{sumSolar}").OrderBy(l => l.From, StringComparer.Ordinal).ToList(); Assert.Equal([$"m{solar1}", $"m{solar2}"], incoming.Select(l => l.From).Order(StringComparer.Ordinal)); Assert.All(incoming, l => { Assert.True(l.IsCalculated); Assert.Equal(15, l.Value, 1); }); Assert.DoesNotContain(graph.Links, l => l.From == $"m{solar3}"); // House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar, an estimate. var fromSum = graph.Links.Single(l => l.From == $"m{sumSolar}" && l.To == $"m{house}"); Assert.Equal(40.0 * 30 / 105, fromSum.Value, 1); Assert.True(fromSum.IsEstimated); // No spurious remainder under Solar 1/2 (their whole output is in Sum Solar). Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1}"); // The top-level throughput is the roots: grid and the three solar meters — never the sum on top of its sources. Assert.Equal(75 + 15 + 15 + 5, graph.Total, 1); } [Fact] public async Task A_legacy_virtual_meter_is_its_implied_sum_until_confirmed() { var type = await TypeAsync(); var solar1 = await MeterAsync(type, "Solar 1", 15, MeterMode.GenerationCounter); var solar2 = await MeterAsync(type, "Solar 2", 20, MeterMode.GenerationCounter); var legacy = await VirtualAsync(type, "Legacy sum", expression: null, QuantityKind.Generation, VirtualCostRule.SourceCosts); await LinkAsync(solar1, legacy); await LinkAsync(solar2, legacy); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); var entry = graph.MeterFor(legacy)!; Assert.Equal(SeriesBasis.LegacyVirtual, entry.Basis); Assert.Equal(35, entry.Value!.Value, 6); Assert.Equal(ValueIssue.LegacyDefinition, entry.Issue); Assert.Equal(2, graph.Links.Count(l => l.To == $"m{legacy}" && l.IsCalculated)); Assert.Contains(graph.Problems, p => p.Kind == AnalysisProblemKind.LegacyDefinition && p.MeterId == legacy); } [Fact] public async Task A_virtual_meter_that_is_not_a_sum_is_only_in_the_table() { var type = await TypeAsync(); var a = await MeterAsync(type, "A", 100, MeterMode.GenerationCounter); var b = await MeterAsync(type, "B", 150, MeterMode.GenerationCounter); var difference = await VirtualAsync(type, "A minus B", $"m{a} - m{b}", QuantityKind.Generation, VirtualCostRule.None); var house = await MeterAsync(type, "House", 40); await LinkAsync(a, difference); await LinkAsync(difference, house); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); // Signed, from its formula — not the flow's old sum of its links (100) — and never drawn. var entry = graph.MeterFor(difference)!; Assert.Equal(-50, entry.Value!.Value, 6); Assert.Equal(BucketStatus.Available, entry.Status); Assert.False(entry.InDiagram); Assert.DoesNotContain(graph.Nodes, n => n.MeterId == difference); Assert.DoesNotContain(graph.Links, l => l.From == $"m{difference}" || l.To == $"m{difference}"); // Its sources and the house are still meters of the diagram. Assert.True(graph.MeterFor(a)!.InDiagram); Assert.Equal(40, graph.MeterFor(house)!.Value!.Value, 6); } [Fact] public async Task A_link_never_carries_more_than_its_parent_measured() { var type = await TypeAsync(); var parent = await MeterAsync(type, "Parent", 50); var child = await MeterAsync(type, "Child", 80); await LinkAsync(parent, child); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); var link = Assert.Single(graph.Links); Assert.Equal(50, link.Value, 6); Assert.True(link.IsCapped); Assert.True(link.IsEstimated); Assert.DoesNotContain(graph.Nodes, n => n.IsOther); // The child's own total is untouched: capping is a drawing rule, not a correction. Assert.Equal(80, graph.MeterFor(child)!.Value!.Value, 6); Assert.Equal(80, graph.Nodes.Single(n => n.MeterId == child).Value, 6); } [Fact] public async Task A_sub_meter_without_data_is_not_a_zero() { var type = await TypeAsync(); var main = await MeterAsync(type, "Main", 100); var silent = await MeterAsync(type, "Silent", amount: null); await LinkAsync(main, silent); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); var entry = graph.MeterFor(silent)!; Assert.Null(entry.Value); Assert.Equal(BucketStatus.Missing, entry.Status); // It keeps its place in the topology, drawn at zero and saying why, but nothing flows to it and the main meter's // total does not turn into "Other": what the silent meter used is unknown, not zero. Assert.Equal(BucketStatus.Missing, graph.Nodes.Single(n => n.MeterId == silent).Status); Assert.Empty(graph.Links); Assert.DoesNotContain(graph.Nodes, n => n.IsOther); } [Fact] public async Task Meters_in_another_unit_or_type_stay_out_of_the_diagram() { var type = await TypeAsync(); var otherType = await TypeAsync("m3"); var main = await MeterAsync(type, "Main", 100); var water = await MeterAsync(type, "Stray water meter", 7, unit: "m3"); var foreign = await MeterAsync(otherType, "Foreign", 12, unit: "m3"); await LinkAsync(main, water); await LinkAsync(foreign, main); var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear); // A flow never adds units: the m³ meter is in the table, with its own number and unit, but not in the diagram. var entry = graph.MeterFor(water)!; Assert.False(entry.InDiagram); Assert.Equal(7, entry.Value!.Value, 6); Assert.Equal("kWh", graph.Unit); Assert.DoesNotContain(graph.Nodes, n => n.MeterId == water); // A meter of another type is not part of this flow at all. Assert.Null(graph.MeterFor(foreign)); Assert.DoesNotContain(graph.Nodes, n => n.MeterId == foreign); Assert.Empty(graph.Links); Assert.Equal(100, graph.Total, 6); } [Fact] public async Task A_range_reaching_past_now_counts_actuals_only() { var type = await TypeAsync(); var main = await MeterAsync(type, "Main", 100); // In July 2024 the reading that closes 2024 lies in the future: nothing of it is an actual yet (D-04). var july = new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero); var graph = await new FlowService(fx, time: new FixedTimeProvider(july)).GetFlowAsync(type, Year, NextYear); Assert.Null(graph.MeterFor(main)!.Value); Assert.Contains(graph.Problems, p => p.Kind == AnalysisProblemKind.RecordedAfterNow && p.MeterId == main); Assert.Equal(0, graph.Total, 6); } // ------------------------------------------------------------------------------------------------ helpers private async Task TypeAsync(string unit = "kWh") { await using var db = fx.CreateContext(); var type = new EnergyType { Key = $"flow-{Guid.NewGuid():N}", DisplayName = "Flow test", BaseUnit = unit, DefaultMode = MeterMode.CumulativeCounter }; db.EnergyTypes.Add(type); await db.SaveChangesAsync(); _types.Add(type.Id); return type.Id; } /// A counter installed on 1 January 2024 whose one reading at the start of 2025 books over 2024; none without an amount. private async Task MeterAsync(short type, string name, double? amount, MeterMode mode = MeterMode.CumulativeCounter, string unit = "kWh") { await using var db = fx.CreateContext(); var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = unit, InstalledAt = Year }; db.Meters.Add(meter); await db.SaveChangesAsync(); _meters.Add(meter.Id); if (amount is { } value) { db.Readings.Add(new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), Value = value, Quality = ReadingQuality.Manual, }); await db.SaveChangesAsync(); } // Also without readings: the rollup state says the (empty) analysis data is current, not being prepared. await new NormalizationService(db, NormalizationEngine.CreateDefault()).RecomputeMeterAsync(meter.Id, null); await db.SaveChangesAsync(); return meter.Id; } /// A virtual meter with the given formula; without one, a legacy meter (Meta "{}"). private async Task VirtualAsync(short type, string name, string? expression, QuantityKind kind, VirtualCostRule rule) { await using var db = fx.CreateContext(); var meta = expression is null ? "{}" : VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, "kWh", rule)); var meter = new Meter { Name = name, EnergyTypeId = type, Mode = MeterMode.Virtual, Unit = "kWh", Meta = meta }; db.Meters.Add(meter); await db.SaveChangesAsync(); _meters.Add(meter.Id); return meter.Id; } private async Task LinkAsync(int from, int to) { await using var db = fx.CreateContext(); db.MeterLinks.Add(new MeterLink { FromMeterId = from, ToMeterId = to }); await db.SaveChangesAsync(); } }