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
@@ -16,6 +16,7 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
public DbSet<EnergyType> EnergyTypes => Set<EnergyType>();
public DbSet<Meter> Meters => Set<Meter>();
public DbSet<MeterSource> MeterSources => Set<MeterSource>();
public DbSet<MeterLink> MeterLinks => Set<MeterLink>();
public DbSet<Reading> Readings => Set<Reading>();
public DbSet<Consumption> Consumption => Set<Consumption>();
public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>();
@@ -74,6 +75,16 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
e.HasIndex(x => x.MeterId);
});
b.Entity<MeterLink>(e =>
{
e.ToTable("meter_link");
e.HasKey(x => x.Id);
e.HasOne(x => x.FromMeter).WithMany().HasForeignKey(x => x.FromMeterId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.ToMeter).WithMany().HasForeignKey(x => x.ToMeterId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.FromMeterId, x.ToMeterId }).IsUnique();
e.ToTable(t => t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id"));
});
// Hypertable — the (meter_id, time) PK contains the partition column (time),
// which Timescale requires. Converted to a hypertable in a raw-SQL migration.
b.Entity<Reading>(e =>