using MeterVault.Core.Domain; using MeterVault.Infrastructure.Dashboard; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Integration.Tests; /// /// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full /// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the /// child's consumption proportionally to the parents' own consumption. /// [Collection("Timescale")] public sealed class FlowServiceTests(TimescaleFixture fx) { [Fact] public async Task Single_parent_chain_makes_other_remainder() { await using var db = fx.CreateContext(); try { var type = await SeedTypeAsync(db, "flow_elec_a"); var main = await AddMeterAsync(db, "Main", type); var car = await AddMeterAsync(db, "Car", type); db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id }); await db.SaveChangesAsync(); await AddConsumptionAsync(db, main.Id, 100); await AddConsumptionAsync(db, car.Id, 30); var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31)); Assert.Equal(100, graph.Total, 1); var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}"); Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent var other = Assert.Single(graph.Nodes, n => n.IsOther); Assert.Equal(70, other.Value, 1); // 100 − 30 } finally { await ClearAsync(db); } } [Fact] public async Task Two_parents_split_child_proportionally() { await using var db = fx.CreateContext(); try { var type = await SeedTypeAsync(db, "flow_elec_b"); var grid = await AddMeterAsync(db, "Grid", type); var solar = await AddMeterAsync(db, "Solar draw", type); var house = await AddMeterAsync(db, "House", type); db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id }); db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id }); await db.SaveChangesAsync(); await AddConsumptionAsync(db, grid.Id, 75); await AddConsumptionAsync(db, solar.Id, 25); await AddConsumptionAsync(db, house.Id, 40); var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31)); // House (40) splits 75:25 → 30 from grid, 10 from solar. Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1); Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1); } finally { await ClearAsync(db); } } [Fact] public async Task Generation_meter_counts_as_source() { await using var db = fx.CreateContext(); try { var type = await SeedTypeAsync(db, "flow_elec_c"); var grid = await AddMeterAsync(db, "Grid", type); var solar = await AddMeterAsync(db, "Solar", type); var house = await AddMeterAsync(db, "House", type); db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id }); db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id }); await db.SaveChangesAsync(); await AddConsumptionAsync(db, grid.Id, 75); // grid import await AddConsumptionAsync(db, solar.Id, 30, ConsumptionKind.Generation); // solar generation await AddConsumptionAsync(db, house.Id, 40); // house load var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31)); // 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.Id}" && l.To == $"m{house.Id}").Value, 1); Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").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); } finally { await ClearAsync(db); } } [Fact] public async Task Virtual_sum_meter_aggregates_its_upstreams() { await using var db = fx.CreateContext(); try { var type = await SeedTypeAsync(db, "flow_elec_d"); var solar1 = await AddMeterAsync(db, "Solar 1", type); var solar2 = await AddMeterAsync(db, "Solar 2", type); var sumSolar = await AddMeterAsync(db, "Sum Solar", type, MeterMode.Virtual); var grid = await AddMeterAsync(db, "Grid", type); var house = await AddMeterAsync(db, "House", type); // Solar1 + Solar2 → Sum Solar ; Grid + Sum Solar → House. db.MeterLinks.Add(new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id }); db.MeterLinks.Add(new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id }); db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id }); db.MeterLinks.Add(new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = house.Id }); await db.SaveChangesAsync(); await AddConsumptionAsync(db, solar1.Id, 15, ConsumptionKind.Generation); await AddConsumptionAsync(db, solar2.Id, 15, ConsumptionKind.Generation); await AddConsumptionAsync(db, grid.Id, 75); await AddConsumptionAsync(db, house.Id, 40); var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31)); // Sum Solar has no readings but equals Solar 1 + Solar 2 = 30. Assert.Equal(30, graph.Nodes.Single(n => n.MeterId == sumSolar.Id).Value, 1); // House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar. Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{sumSolar.Id}" && l.To == $"m{house.Id}").Value, 1); // No spurious remainder under Solar 1/2 (their whole output flows into Sum Solar). Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1.Id}"); } finally { await ClearAsync(db); } } private static async Task SeedTypeAsync(MeterVaultDbContext db, string key) { var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter }; db.EnergyTypes.Add(type); await db.SaveChangesAsync(); return type.Id; } private static async Task AddMeterAsync(MeterVaultDbContext db, string name, short type, MeterMode mode = MeterMode.DirectDelta) { var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = "kWh" }; db.Meters.Add(meter); await db.SaveChangesAsync(); return meter; } private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption) { db.Consumption.Add(new Consumption { MeterId = meterId, Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero), Amount = amount, Kind = kind, Quality = ReadingQuality.Manual, }); await db.SaveChangesAsync(); } private static async Task ClearAsync(MeterVaultDbContext db) { await db.MeterLinks.ExecuteDeleteAsync(); await db.Consumption.ExecuteDeleteAsync(); await db.Meters.ExecuteDeleteAsync(); await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync(); } }