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
+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;
}