Files
MeterVault/src/Infrastructure/Persistence/Migrations/20260714114901_AddMeterLinks.cs
T
schmidt.florian 7e34aeccc8
ci / build-test (push) Successful in 1m24s
Meter chain topology + per-energy-type flow (Sankey) pages
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
2026-07-14 14:02:16 +02:00

61 lines
2.3 KiB
C#

using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMeterLinks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "meter_link",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
from_meter_id = table.Column<int>(type: "integer", nullable: false),
to_meter_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_meter_link", x => x.id);
table.CheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
table.ForeignKey(
name: "fk_meter_link_meter_from_meter_id",
column: x => x.from_meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_meter_link_meter_to_meter_id",
column: x => x.to_meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_meter_link_from_meter_id_to_meter_id",
table: "meter_link",
columns: new[] { "from_meter_id", "to_meter_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_meter_link_to_meter_id",
table: "meter_link",
column: "to_meter_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "meter_link");
}
}
}