Flow: virtual "sum" meters that aggregate upstreams + demo chain
ci / build-test (push) Successful in 1m14s

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
This commit is contained in:
2026-07-14 15:48:50 +02:00
parent ecadbe1477
commit 92438348e7
5 changed files with 73 additions and 7 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ else
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder. Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
</MudText> </MudText>
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="_graph.Unit" /> <SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
} }
else else
{ {
+7
View File
@@ -78,6 +78,13 @@ else
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem> <MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (_working.Mode == MeterMode.Virtual)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
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).
</MudAlert>
}
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" /> <MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" /> <MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2"> <MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
@@ -59,6 +59,18 @@ public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFa
var depth = ComputeDepths(meters.Select(m => m.Id).ToList(), parents, children); 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 // 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). // split proportionally to the parents' own consumption (equal split if those are all zero).
var flowLinks = new List<FlowLink>(); var flowLinks = new List<FlowLink>();
@@ -51,13 +51,15 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820); var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L"); var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h"); 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 // 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). // self-consumption = total_load grid_import and savings generically (SDD §8.4).
haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad); haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad);
netz.Meta = MeterMeta.WithRole(netz.Meta, MeterRoles.GridImport); 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); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_db.Tanks.Add(new Tank _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)}}}", 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 // Demo flow chain (electricity /energy view): Solar 1 + Solar 2 → Summe Solar; then
// electricity flow view shows Haus dividing into Auto + an "Other" remainder. // Grid + Summe Solar → Haus → Auto + "Other". The remainder under Grid/Summe Solar is the
_db.MeterLinks.Add(new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id }); // 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); AddElectricityTariffs(electricity);
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1)); AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
+41 -2
View File
@@ -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<short> SeedTypeAsync(MeterVaultDbContext db, string key) private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
{ {
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter }; 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; return type.Id;
} }
private static async Task<Meter> AddMeterAsync(MeterVaultDbContext db, string name, short type) private static async Task<Meter> 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); db.Meters.Add(meter);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return meter; return meter;