7e34aeccc8
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
112 lines
4.4 KiB
C#
112 lines
4.4 KiB
C#
using MeterVault.Core.Domain;
|
||
using MeterVault.Infrastructure.Dashboard;
|
||
using MeterVault.Infrastructure.Persistence;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace MeterVault.Integration.Tests;
|
||
|
||
/// <summary>
|
||
/// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full
|
||
/// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the
|
||
/// child's consumption proportionally to the parents' own consumption.
|
||
/// </summary>
|
||
[Collection("Timescale")]
|
||
public sealed class FlowServiceTests(TimescaleFixture fx)
|
||
{
|
||
[Fact]
|
||
public async Task Single_parent_chain_makes_other_remainder()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
try
|
||
{
|
||
var type = await SeedTypeAsync(db, "flow_elec_a");
|
||
var main = await AddMeterAsync(db, "Main", type);
|
||
var car = await AddMeterAsync(db, "Car", type);
|
||
db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id });
|
||
await db.SaveChangesAsync();
|
||
|
||
await AddConsumptionAsync(db, main.Id, 100);
|
||
await AddConsumptionAsync(db, car.Id, 30);
|
||
|
||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||
|
||
Assert.Equal(100, graph.Total, 1);
|
||
var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}");
|
||
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
|
||
var other = Assert.Single(graph.Nodes, n => n.IsOther);
|
||
Assert.Equal(70, other.Value, 1); // 100 − 30
|
||
}
|
||
finally
|
||
{
|
||
await ClearAsync(db);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Two_parents_split_child_proportionally()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
try
|
||
{
|
||
var type = await SeedTypeAsync(db, "flow_elec_b");
|
||
var grid = await AddMeterAsync(db, "Grid", type);
|
||
var solar = await AddMeterAsync(db, "Solar draw", type);
|
||
var house = await AddMeterAsync(db, "House", type);
|
||
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
|
||
await db.SaveChangesAsync();
|
||
|
||
await AddConsumptionAsync(db, grid.Id, 75);
|
||
await AddConsumptionAsync(db, solar.Id, 25);
|
||
await AddConsumptionAsync(db, house.Id, 40);
|
||
|
||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||
|
||
// House (40) splits 75:25 → 30 from grid, 10 from solar.
|
||
Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||
Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||
}
|
||
finally
|
||
{
|
||
await ClearAsync(db);
|
||
}
|
||
}
|
||
|
||
private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
|
||
{
|
||
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
|
||
db.EnergyTypes.Add(type);
|
||
await db.SaveChangesAsync();
|
||
return type.Id;
|
||
}
|
||
|
||
private static async Task<Meter> AddMeterAsync(MeterVaultDbContext db, string name, short type)
|
||
{
|
||
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = MeterMode.DirectDelta, Unit = "kWh" };
|
||
db.Meters.Add(meter);
|
||
await db.SaveChangesAsync();
|
||
return meter;
|
||
}
|
||
|
||
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount)
|
||
{
|
||
db.Consumption.Add(new Consumption
|
||
{
|
||
MeterId = meterId,
|
||
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
|
||
Amount = amount,
|
||
Kind = ConsumptionKind.Consumption,
|
||
Quality = ReadingQuality.Manual,
|
||
});
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
private static async Task ClearAsync(MeterVaultDbContext db)
|
||
{
|
||
await db.MeterLinks.ExecuteDeleteAsync();
|
||
await db.Consumption.ExecuteDeleteAsync();
|
||
await db.Meters.ExecuteDeleteAsync();
|
||
await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync();
|
||
}
|
||
}
|