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
@@ -59,6 +59,18 @@ public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> 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<FlowLink>();