From ecadbe14776ed56781eef43637ef860f3ba7ab1f Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Tue, 14 Jul 2026 14:33:29 +0200 Subject: [PATCH] =?UTF-8?q?Flow:=20count=20generation=20meters=20as=20sour?= =?UTF-8?q?ces=20(grid=20+=20solar=20=E2=86=92=20house)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A meter's flow value is now its throughput — consumption OR generation output — so a generation meter (solar) acts as a source that can feed downstream meters. Setting a load meter's upstream to {grid, solar} now splits its consumption across both proportionally, and the remainder under the sources (grid + solar − load) surfaces as "Other" = export + battery/inverter losses. Negative values (savings/balance virtuals) are clamped to 0 (a ribbon can't be negative). The per-type KPI is relabelled "Top-level throughput" since it now spans generation. Test: Generation_meter_counts_as_source (grid 75 + solar-gen 30 → house 40 → 28.57/11.43 split, 65 remainder). 69 Core + 48 Integration = 117 green. Live-verified: electricity flow now shows Solar 1/2 as source nodes. Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK --- src/App/Components/Pages/EnergyView.razor | 2 +- src/Infrastructure/Dashboard/FlowService.cs | 8 +++-- tests/Integration.Tests/FlowServiceTests.cs | 36 +++++++++++++++++++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/App/Components/Pages/EnergyView.razor b/src/App/Components/Pages/EnergyView.razor index 85f4998..c45a156 100644 --- a/src/App/Components/Pages/EnergyView.razor +++ b/src/App/Components/Pages/EnergyView.razor @@ -33,7 +33,7 @@ else - Top-level consumption + Top-level throughput @Format.Number(_graph.Total, 0) @_graph.Unit diff --git a/src/Infrastructure/Dashboard/FlowService.cs b/src/Infrastructure/Dashboard/FlowService.cs index fdda31b..c4a9af7 100644 --- a/src/Infrastructure/Dashboard/FlowService.cs +++ b/src/Infrastructure/Dashboard/FlowService.cs @@ -33,12 +33,16 @@ public sealed class FlowService(IDbContextFactory contextFa var fromUtc = ToUtc(from); var toUtc = ToUtc(to); + // A meter's flow value is its throughput: consumption OR generation output — so a generation + // meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter + // is normally one kind, so summing both kinds is that meter's flow. Negatives (savings/balance + // virtual meters) are clamped to 0 — a flow ribbon can't be negative. var sums = await db.Consumption.AsNoTracking() - .Where(c => c.Time >= fromUtc && c.Time < toUtc && c.Kind == ConsumptionKind.Consumption) + .Where(c => c.Time >= fromUtc && c.Time < toUtc) .GroupBy(c => c.MeterId) .Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) }) .ToListAsync(cancellationToken).ConfigureAwait(false); - var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => s.Total); + var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => Math.Max(0, s.Total)); double V(int id) => value.GetValueOrDefault(id); var links = await db.MeterLinks.AsNoTracking() diff --git a/tests/Integration.Tests/FlowServiceTests.cs b/tests/Integration.Tests/FlowServiceTests.cs index 8a146c9..cd8e39b 100644 --- a/tests/Integration.Tests/FlowServiceTests.cs +++ b/tests/Integration.Tests/FlowServiceTests.cs @@ -72,6 +72,38 @@ public sealed class FlowServiceTests(TimescaleFixture fx) } } + [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); + } + } + private static async Task SeedTypeAsync(MeterVaultDbContext db, string key) { var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter }; @@ -88,14 +120,14 @@ public sealed class FlowServiceTests(TimescaleFixture fx) return meter; } - private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount) + 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 = ConsumptionKind.Consumption, + Kind = kind, Quality = ReadingQuality.Manual, }); await db.SaveChangesAsync();