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
159 lines
7.4 KiB
C#
159 lines
7.4 KiB
C#
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);
|
|
|
|
// A meter's flow value is its throughput: consumption OR generation output — so a generation
|
|
// meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter
|
|
// is normally one kind, so summing both kinds is that meter's flow. Negatives (savings/balance
|
|
// virtual meters) are clamped to 0 — a flow ribbon can't be negative.
|
|
var sums = await db.Consumption.AsNoTracking()
|
|
.Where(c => c.Time >= fromUtc && c.Time < toUtc)
|
|
.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 => Math.Max(0, 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);
|
|
|
|
// 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>();
|
|
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);
|
|
}
|