From 92438348e7b8c83197218c87435bec5794bf07dd Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Tue, 14 Jul 2026 15:48:50 +0200 Subject: [PATCH] Flow: virtual "sum" meters that aggregate upstreams + demo chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the "sum meter" the user asked for: a meter that doesn't physically exist but represents the sum of other meters in the flow view. - FlowService: a Virtual-mode meter has no readings of its own; its flow value is the sum of its upstream meters, resolved in topological order (so "Summe Solar" = Solar 1 + Solar 2, and Grid + Summe Solar → House with the remainder as "Other" = export / battery / inverter losses). - Meters editor: a hint when Mode = Virtual explaining the sum-meter behaviour. - Reference data seeds the full demo chain: Solar 1 + Solar 2 → Summe Solar; Netz + Summe Solar → Haus → Auto + Other — so /energy/1 shows a multi-level Sankey out of the box. - Fix: SankeyChart Unit was passed as the literal string "_graph.Unit" (missing @) so node labels read "_graph.Unit" instead of "kWh". Caught by screenshotting the live page. Test: Virtual_sum_meter_aggregates_its_upstreams. 69 Core + 50 Integration = 119 green. Live-verified with a browser screenshot of the multi-level flow. Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK --- src/App/Components/Pages/EnergyView.razor | 2 +- src/App/Components/Pages/Meters.razor | 7 +++ src/Infrastructure/Dashboard/FlowService.cs | 12 ++++++ .../Import/ReferenceDataImporter.cs | 16 +++++-- tests/Integration.Tests/FlowServiceTests.cs | 43 ++++++++++++++++++- 5 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/App/Components/Pages/EnergyView.razor b/src/App/Components/Pages/EnergyView.razor index c45a156..970840a 100644 --- a/src/App/Components/Pages/EnergyView.razor +++ b/src/App/Components/Pages/EnergyView.razor @@ -58,7 +58,7 @@ else Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder. - + } else { diff --git a/src/App/Components/Pages/Meters.razor b/src/App/Components/Pages/Meters.razor index cb57018..ca58939 100644 --- a/src/App/Components/Pages/Meters.razor +++ b/src/App/Components/Pages/Meters.razor @@ -78,6 +78,13 @@ else @mode } + @if (_working.Mode == MeterMode.Virtual) + { + + Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the + upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2). + + } diff --git a/src/Infrastructure/Dashboard/FlowService.cs b/src/Infrastructure/Dashboard/FlowService.cs index c4a9af7..fd163e2 100644 --- a/src/Infrastructure/Dashboard/FlowService.cs +++ b/src/Infrastructure/Dashboard/FlowService.cs @@ -59,6 +59,18 @@ public sealed class FlowService(IDbContextFactory contextFa var depth = ComputeDepths(meters.Select(m => m.Id).ToList(), parents, children); + // Aggregate ("sum") meters: a Virtual-mode meter has no measurements of its own — in the flow + // it is the sum of its upstream meters (e.g. "Sum Solar" = Solar 1 + Solar 2). Resolve these in + // topological (depth) order so each aggregate sees its already-resolved upstream values. + var aggregates = meters.Where(m => m.Mode == MeterMode.Virtual).Select(m => m.Id).ToHashSet(); + foreach (var id in meters.Select(m => m.Id).OrderBy(id => depth.GetValueOrDefault(id))) + { + if (aggregates.Contains(id)) + { + value[id] = parents[id].Sum(V); + } + } + // Link value: a child's consumption flows in from its parent(s); with several parents it is // split proportionally to the parents' own consumption (equal split if those are all zero). var flowLinks = new List(); diff --git a/src/Infrastructure/Import/ReferenceDataImporter.cs b/src/Infrastructure/Import/ReferenceDataImporter.cs index 8b5e365..a748ae7 100644 --- a/src/Infrastructure/Import/ReferenceDataImporter.cs +++ b/src/Infrastructure/Import/ReferenceDataImporter.cs @@ -51,13 +51,15 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820); var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L"); var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h"); + // A virtual "sum" meter: no readings of its own — in the flow view it equals Solar 1 + Solar 2. + var sumSolar = Meter("Summe Solar", electricity, MeterMode.Virtual, "kWh"); // Tag the PV meters' roles (config, not hardcoded names) so the Solar panel can derive // self-consumption = total_load − grid_import and savings generically (SDD §8.4). haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad); netz.Meta = MeterMeta.WithRole(netz.Meta, MeterRoles.GridImport); - _db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner); + _db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner, sumSolar); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _db.Tanks.Add(new Tank @@ -68,9 +70,15 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}", }); - // Demo flow chain: car charging is a subsection of total house load (Haus → Auto), so the - // electricity flow view shows Haus dividing into Auto + an "Other" remainder. - _db.MeterLinks.Add(new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id }); + // Demo flow chain (electricity /energy view): Solar 1 + Solar 2 → Summe Solar; then + // Grid + Summe Solar → Haus → Auto + "Other". The remainder under Grid/Summe Solar is the + // input that didn't reach the house load (solar export / battery / inverter losses). + _db.MeterLinks.AddRange( + new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id }, + new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id }, + new MeterLink { FromMeterId = netz.Id, ToMeterId = haus.Id }, + new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = haus.Id }, + new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id }); AddElectricityTariffs(electricity); AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1)); diff --git a/tests/Integration.Tests/FlowServiceTests.cs b/tests/Integration.Tests/FlowServiceTests.cs index cd8e39b..fc54cdd 100644 --- a/tests/Integration.Tests/FlowServiceTests.cs +++ b/tests/Integration.Tests/FlowServiceTests.cs @@ -104,6 +104,45 @@ public sealed class FlowServiceTests(TimescaleFixture fx) } } + [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 }; @@ -112,9 +151,9 @@ public sealed class FlowServiceTests(TimescaleFixture fx) return type.Id; } - private static async Task AddMeterAsync(MeterVaultDbContext db, string name, short type) + private static async Task AddMeterAsync(MeterVaultDbContext db, string name, short type, MeterMode mode = MeterMode.DirectDelta) { - var meter = new Meter { Name = name, EnergyTypeId = type, Mode = MeterMode.DirectDelta, Unit = "kWh" }; + var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = "kWh" }; db.Meters.Add(meter); await db.SaveChangesAsync(); return meter;