Meter chain topology + per-energy-type flow (Sankey) pages
ci / build-test (push) Successful in 1m24s
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:
@@ -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) : "—";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user