Meter chain topology + per-energy-type flow (Sankey) pages
ci / build-test (push) Successful in 1m24s

Adds a meter hierarchy and a flow view: a downstream meter is a *subsection*
of an upstream one (not an addition), so you can see where a main meter's flow
divides — e.g. official water → garden, pool, other; grid/battery → all → car.

- MeterLink (schema + migration AddMeterLinks): a directed from→to flow edge.
  Multi-parent allowed (a merge, e.g. grid + solar → house); multi-child is a
  split. Cascade-deletes with either endpoint; unique + distinct-endpoint checks.
- FlowService: per energy type + period, builds a Sankey graph — nodes = meters
  sized by consumption; link value = downstream meter's consumption, split
  proportionally across multiple upstreams; unaccounted remainder under a meter
  becomes a synthetic "Other" node; depth via topological longest-path.
- SankeyChart.razor: hand-rolled inline-SVG Sankey (ApexCharts has no Sankey
  type) — columns by depth, nodes stacked by value, bezier ribbons sized by flow,
  left→right, theme-aware, HTML-encoded labels, tooltips. Built as a MarkupString
  to sidestep Razor's <text> element clash.
- /energy/{id} page (one per energy type): KPIs (consumption + cost), the flow
  Sankey, and the meter list. NavMenu now lists a link per energy type
  (Electricity, Water, Gas, …) loaded from the DB.
- Meters admin: cycle-safe "Sub-meter of (upstream meters)" multi-select
  (descendants excluded to prevent cycles); reconciles meter_link rows on save.
- Reference data seeds a demo chain (Haus → Auto) so electricity flow shows
  Haus dividing into Auto + Other.

Tests: FlowServiceTests (single-parent remainder; two-parent proportional
split); render test now asserts the flow chain + covers /energy/{id}. 69 Core +
47 Integration = 116 green. Live-verified: Haus 95,450 kWh → Auto 51,909 +
Other 43,541 (flow conserved), all 5 energy-type pages render.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
2026-07-14 14:02:16 +02:00
parent 09cd435c2b
commit 7e34aeccc8
17 changed files with 1841 additions and 5 deletions
+39
View File
@@ -1,6 +1,16 @@
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@using Microsoft.EntityFrameworkCore
@using MeterVault.Core.Domain
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
@foreach (var type in _energyTypes)
{
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
}
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
@@ -14,3 +24,32 @@
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
</MudNavGroup>
</MudNavMenu>
@code {
private List<EnergyType> _energyTypes = [];
protected override async Task OnInitializedAsync()
{
try
{
await using var db = await DbFactory.CreateDbContextAsync();
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
}
catch (Exception)
{
// Nav must never break the layout — a DB hiccup just hides the per-type links.
_energyTypes = [];
}
}
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
private static string TypeIcon(string? icon) => icon switch
{
"bolt" => Icons.Material.Filled.Bolt,
"water_drop" => Icons.Material.Filled.WaterDrop,
"local_gas_station" => Icons.Material.Filled.LocalGasStation,
"gas_meter" => Icons.Material.Filled.GasMeter,
"thermostat" => Icons.Material.Filled.Thermostat,
_ => Icons.Material.Filled.Bolt,
};
}
+173
View File
@@ -0,0 +1,173 @@
@page "/energy/{Id:int}"
@rendermode InteractiveServer
@inject FlowService Flow
@inject MeterVault.Infrastructure.Costing.CostService Costs
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
</MudSelect>
</div>
@if (_graph is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (!_graph.HasData)
{
<MudAlert Severity="Severity.Info">
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
reference data from <MudLink Href="/import">Import</MudLink>.
</MudAlert>
}
else
{
<MudGrid Class="mb-2">
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level consumption</MudText>
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
<MudText Typo="Typo.h5">@_meters.Count</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
@if (_graph.HasChain)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
</MudText>
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="_graph.Unit" />
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
</MudAlert>
@if (_graph.Nodes.Count > 0)
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
<tbody>
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
{
<tr>
<td>@node.Label</td>
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
}
</MudPaper>
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
<tbody>
@foreach (var meter in _meters)
{
<tr>
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
<td>@meter.Mode</td>
<td>@UpstreamLabel(meter.Id)</td>
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
}
@code {
[Parameter]
public int Id { get; set; }
private int _months = 60;
private bool _loading;
private FlowGraph? _graph;
private double _cost;
private List<Meter> _meters = [];
private Dictionary<int, List<string>> _downstream = [];
protected override Task OnParametersSetAsync() => LoadAsync();
private async Task OnRangeChanged(int months)
{
_months = months;
await LoadAsync();
}
private async Task LoadAsync()
{
if (_loading)
{
return;
}
_loading = true;
_graph = null;
try
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
var to = asOf.AddMonths(1);
var typeId = (short)Id;
_graph = await Flow.GetFlowAsync(typeId, from, to);
await using var db = await DbFactory.CreateDbContextAsync();
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
var links = await db.MeterLinks.AsNoTracking()
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
.ToListAsync();
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
_downstream = links
.GroupBy(l => l.FromMeterId)
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
double cost = 0;
foreach (var meter in _meters)
{
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
}
_cost = cost;
}
finally
{
_loading = false;
}
}
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
private string UpstreamLabel(int meterId) =>
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
}
+83 -2
View File
@@ -87,6 +87,15 @@ else
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
</MudSelect>
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
Label="Sub-meter of (upstream meters)" Class="mb-2"
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
@foreach (var m in AvailableUpstream())
{
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
<div class="d-flex" style="gap:1rem">
@@ -108,6 +117,7 @@ else
@code {
private List<Meter>? _meters;
private List<EnergyType> _energyTypes = [];
private List<MeterLink> _allLinks = [];
private bool _editOpen;
private EditModel _working = new();
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
@@ -118,6 +128,7 @@ else
{
await using var db = await DbFactory.CreateDbContextAsync();
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
_meters = await db.Meters
.AsNoTracking()
.Include(m => m.EnergyType)
@@ -126,6 +137,49 @@ else
.ToListAsync();
}
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
private IEnumerable<Meter> AvailableUpstream()
{
if (_meters is null)
{
return [];
}
var descendants = Descendants(_working.Id);
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
}
private HashSet<int> Descendants(int meterId)
{
var result = new HashSet<int>();
if (meterId == 0)
{
return result;
}
var queue = new Queue<int>();
queue.Enqueue(meterId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
{
if (result.Add(link.ToMeterId))
{
queue.Enqueue(link.ToMeterId);
}
}
}
return result;
}
private string UpstreamText(IReadOnlyList<string> ids)
{
var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
return string.Join(", ", names);
}
private void OpenEdit(Meter? meter)
{
if (meter is null)
@@ -150,6 +204,7 @@ else
Manufacturer = meter.Manufacturer,
Model = meter.Model,
IsActive = meter.IsActive,
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
};
}
_editOpen = true;
@@ -164,9 +219,10 @@ else
}
await using var db = await DbFactory.CreateDbContextAsync();
int meterId;
if (_working.Id == 0)
{
db.Meters.Add(new Meter
var meter = new Meter
{
Name = _working.Name.Trim(),
EnergyTypeId = _working.EnergyTypeId,
@@ -179,8 +235,10 @@ else
Manufacturer = Trim(_working.Manufacturer),
Model = Trim(_working.Model),
IsActive = _working.IsActive,
});
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
meterId = meter.Id;
}
else
{
@@ -208,13 +266,35 @@ else
}
await tx.CommitAsync();
meterId = existing.Id;
}
await SyncUpstreamAsync(db, meterId, _working.Upstream);
_editOpen = false;
Snackbar.Add("Saved.", Severity.Success);
await LoadAsync();
}
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
{
var desired = desiredUpstream.Where(id => id != meterId).ToHashSet();
var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
{
db.MeterLinks.Remove(link);
}
foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
{
db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
}
await db.SaveChangesAsync();
}
private async Task DeleteAsync(Meter meter)
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -258,6 +338,7 @@ else
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public bool IsActive { get; set; } = true;
public IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
}
+165
View File
@@ -0,0 +1,165 @@
@using System.Globalization
@using System.Text
@using System.Net
@using MeterVault.Infrastructure.Dashboard
@if (string.IsNullOrEmpty(_svg))
{
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText>
}
else
{
<div style="width:100%; overflow-x:auto">
@((MarkupString)_svg)
</div>
}
@code {
private const double W = 1000;
private const double NodeWidth = 16;
private const double NodeGap = 12;
private const double LeftPad = 8;
private const double RightPad = 8;
[Parameter, EditorRequired]
public IReadOnlyList<FlowNode> Nodes { get; set; } = [];
[Parameter, EditorRequired]
public IReadOnlyList<FlowLink> Links { get; set; } = [];
[Parameter]
public string Unit { get; set; } = "";
private string _svg = "";
protected override void OnParametersSet() => _svg = BuildSvg();
private string BuildSvg()
{
if (Nodes.Count == 0)
{
return "";
}
var maxDepth = Nodes.Max(n => n.Depth);
var columns = Nodes.GroupBy(n => n.Depth).ToDictionary(g => g.Key, g => g.OrderByDescending(n => n.Value).ToList());
var maxCount = columns.Values.Max(c => c.Count);
var height = Math.Max(320, (maxCount * 46) + 40);
// One value→pixel scale so every column fits (flow is conserved → column totals are ~equal;
// the densest column with the most gaps constrains the scale).
var scale = double.MaxValue;
foreach (var col in columns.Values)
{
var sum = col.Sum(n => n.Value);
if (sum > 0)
{
scale = Math.Min(scale, (height - ((col.Count - 1) * NodeGap) - 20) / sum);
}
}
if (double.IsInfinity(scale) || scale <= 0)
{
scale = 1;
}
var colStep = maxDepth == 0 ? 0 : (W - LeftPad - RightPad - NodeWidth) / maxDepth;
var geo = new Dictionary<string, NodeGeo>();
foreach (var (depth, col) in columns)
{
var heights = col.Select(n => Math.Max(3, n.Value * scale)).ToList();
var colHeight = heights.Sum() + ((col.Count - 1) * NodeGap);
var y = (height - colHeight) / 2;
var x = LeftPad + (depth * colStep);
for (var i = 0; i < col.Count; i++)
{
geo[col[i].Id] = new NodeGeo(x, y, heights[i]);
y += heights[i] + NodeGap;
}
}
// Ribbon band offsets: order each source's out-links by target y, each target's in-links by source y.
var srcOffset = new Dictionary<string, double>();
var dstOffset = new Dictionary<string, double>();
var srcBand = new Dictionary<FlowLink, double>();
var dstBand = new Dictionary<FlowLink, double>();
foreach (var group in Links.GroupBy(l => l.From))
{
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.To, out var g) ? g.Y : 0))
{
srcBand[link] = srcOffset.GetValueOrDefault(group.Key);
srcOffset[group.Key] = srcBand[link] + (link.Value * scale);
}
}
foreach (var group in Links.GroupBy(l => l.To))
{
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.From, out var g) ? g.Y : 0))
{
dstBand[link] = dstOffset.GetValueOrDefault(group.Key);
dstOffset[group.Key] = dstBand[link] + (link.Value * scale);
}
}
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
var label = Nodes.ToDictionary(n => n.Id, n => n.Label);
var sb = new StringBuilder();
sb.Append(CultureInfo.InvariantCulture,
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"Flow diagram\">");
// Ribbons first (under nodes).
foreach (var link in Links)
{
if (!geo.TryGetValue(link.From, out var s) || !geo.TryGetValue(link.To, out var t))
{
continue;
}
var band = link.Value * scale;
var sy0 = s.Y + srcBand[link];
var ty0 = t.Y + dstBand[link];
var sx = s.X + NodeWidth;
var tx = t.X;
var midX = (sx + tx) / 2;
var path =
$"M{F(sx)},{F(sy0)} C{F(midX)},{F(sy0)} {F(midX)},{F(ty0)} {F(tx)},{F(ty0)} " +
$"L{F(tx)},{F(ty0 + band)} C{F(midX)},{F(ty0 + band)} {F(midX)},{F(sy0 + band)} {F(sx)},{F(sy0 + band)} Z";
var tip = Enc($"{label.GetValueOrDefault(link.From)} → {label.GetValueOrDefault(link.To)}: {Fmt(link.Value)}");
sb.Append(CultureInfo.InvariantCulture,
$"<path d=\"{path}\" fill=\"{Enc(color.GetValueOrDefault(link.From, "#607D8B"))}\" fill-opacity=\"0.38\"><title>{tip}</title></path>");
}
// Nodes + labels.
foreach (var node in Nodes)
{
if (!geo.TryGetValue(node.Id, out var g))
{
continue;
}
var rightmost = node.Depth == maxDepth;
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
var anchor = rightmost ? "end" : "start";
var fill = Enc(node.ColorHex ?? "#607D8B");
var name = Enc(node.Label);
var val = Enc(Fmt(node.Value));
sb.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>");
sb.Append(CultureInfo.InvariantCulture,
$"<text x=\"{F(labelX)}\" y=\"{F(g.Y + (g.H / 2))}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" font-size=\"13\" fill=\"currentColor\">" +
$"<tspan>{name}</tspan><tspan x=\"{F(labelX)}\" dy=\"15\" font-size=\"11\" fill-opacity=\"0.65\">{val}</tspan></text>");
}
sb.Append("</svg>");
return sb.ToString();
}
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim();
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
private static string Enc(string value) => WebUtility.HtmlEncode(value);
private readonly record struct NodeGeo(double X, double Y, double H);
}