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,142 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-energy-type flow graph (Sankey) from the meter topology (<see cref="MeterLink"/>)
|
||||
/// and consumption over a period. Each meter is a node sized by its consumption; each configured
|
||||
/// edge carries the downstream meter's consumption (split proportionally when a meter has several
|
||||
/// upstreams); the unaccounted remainder under a meter becomes a synthetic "Other" node. Nothing is
|
||||
/// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory
|
||||
/// keeps it Blazor-circuit safe.
|
||||
/// </summary>
|
||||
public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private const double Epsilon = 0.01;
|
||||
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<FlowGraph> GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var energyType = await db.EnergyTypes.AsNoTracking().FirstOrDefaultAsync(t => t.Id == energyTypeId, cancellationToken).ConfigureAwait(false);
|
||||
var meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (energyType is null || meters.Count == 0)
|
||||
{
|
||||
return new FlowGraph(energyTypeId, energyType?.DisplayName ?? "", energyType?.BaseUnit ?? "", 0, [], []);
|
||||
}
|
||||
|
||||
var meterIds = meters.Select(m => m.Id).ToHashSet();
|
||||
var fromUtc = ToUtc(from);
|
||||
var toUtc = ToUtc(to);
|
||||
|
||||
var sums = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.Time >= fromUtc && c.Time < toUtc && c.Kind == ConsumptionKind.Consumption)
|
||||
.GroupBy(c => c.MeterId)
|
||||
.Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => s.Total);
|
||||
double V(int id) => value.GetValueOrDefault(id);
|
||||
|
||||
var links = await db.MeterLinks.AsNoTracking()
|
||||
.Where(l => meterIds.Contains(l.FromMeterId) && meterIds.Contains(l.ToMeterId))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var parents = meters.ToDictionary(m => m.Id, _ => new List<int>());
|
||||
var children = meters.ToDictionary(m => m.Id, _ => new List<int>());
|
||||
foreach (var link in links)
|
||||
{
|
||||
children[link.FromMeterId].Add(link.ToMeterId);
|
||||
parents[link.ToMeterId].Add(link.FromMeterId);
|
||||
}
|
||||
|
||||
var depth = ComputeDepths(meters.Select(m => m.Id).ToList(), parents, children);
|
||||
|
||||
// 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>();
|
||||
var outgoingByParent = meters.ToDictionary(m => m.Id, _ => 0d);
|
||||
foreach (var (childId, parentIds) in parents)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parentTotal = parentIds.Sum(V);
|
||||
foreach (var parentId in parentIds)
|
||||
{
|
||||
var share = parentIds.Count == 1 ? 1d
|
||||
: parentTotal > Epsilon ? V(parentId) / parentTotal
|
||||
: 1d / parentIds.Count;
|
||||
var linkValue = V(childId) * share;
|
||||
if (linkValue > Epsilon)
|
||||
{
|
||||
flowLinks.Add(new FlowLink(NodeId(parentId), NodeId(childId), linkValue));
|
||||
outgoingByParent[parentId] += linkValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nodes = new List<FlowNode>();
|
||||
foreach (var meter in meters)
|
||||
{
|
||||
// Keep a meter node if it carries flow or participates in the topology.
|
||||
if (V(meter.Id) <= Epsilon && children[meter.Id].Count == 0 && parents[meter.Id].Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.Add(new FlowNode(NodeId(meter.Id), meter.Name, V(meter.Id), depth.GetValueOrDefault(meter.Id), energyType.ColorHex, false, meter.Id));
|
||||
|
||||
// Unaccounted remainder under a meter with sub-meters → "Other".
|
||||
if (children[meter.Id].Count > 0)
|
||||
{
|
||||
var remainder = V(meter.Id) - outgoingByParent[meter.Id];
|
||||
if (remainder > Epsilon)
|
||||
{
|
||||
var otherId = $"other{meter.Id}";
|
||||
nodes.Add(new FlowNode(otherId, $"Other ({meter.Name})", remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
|
||||
flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var total = meters.Where(m => parents[m.Id].Count == 0).Sum(m => V(m.Id));
|
||||
return new FlowGraph(energyTypeId, energyType.DisplayName, energyType.BaseUnit, total, nodes, flowLinks);
|
||||
}
|
||||
|
||||
/// <summary>Longest-path depth from the roots (Kahn topological relaxation); robust to stray cycles.</summary>
|
||||
private static Dictionary<int, int> ComputeDepths(
|
||||
List<int> ids, Dictionary<int, List<int>> parents, Dictionary<int, List<int>> children)
|
||||
{
|
||||
var depth = ids.ToDictionary(id => id, _ => 0);
|
||||
var indegree = ids.ToDictionary(id => id, id => parents[id].Count);
|
||||
var queue = new Queue<int>(ids.Where(id => indegree[id] == 0));
|
||||
var processed = 0;
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var node = queue.Dequeue();
|
||||
processed++;
|
||||
foreach (var child in children[node])
|
||||
{
|
||||
depth[child] = Math.Max(depth[child], depth[node] + 1);
|
||||
if (--indegree[child] == 0)
|
||||
{
|
||||
queue.Enqueue(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any nodes left (a cycle) keep depth 0 — the admin prevents cycles, this is just a guard.
|
||||
return depth;
|
||||
}
|
||||
|
||||
private static string NodeId(int meterId) => $"m{meterId}";
|
||||
|
||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
Reference in New Issue
Block a user