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
@@ -0,0 +1,26 @@
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId);
/// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary>
public sealed record FlowLink(string From, string To, double Value);
/// <summary>
/// The per-energy-type flow graph (SDD-style topology view): meters as nodes sized by consumption,
/// directed edges sized by the flow along each configured link, plus "Other" remainders where an
/// upstream meter's flow isn't fully accounted for by its sub-meters. Rendered as a Sankey diagram.
/// </summary>
public sealed record FlowGraph(
short EnergyTypeId,
string EnergyType,
string Unit,
double Total,
IReadOnlyList<FlowNode> Nodes,
IReadOnlyList<FlowLink> Links)
{
public bool HasData => Nodes.Count > 0;
/// <summary>True when meters are actually chained (not just a flat, unlinked list).</summary>
public bool HasChain => Links.Count > 0;
}
+142
View File
@@ -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);
}
@@ -42,6 +42,7 @@ public static class DependencyInjection
services.AddScoped<Dashboard.SolarService>();
services.AddScoped<Dashboard.ConsumableService>();
services.AddScoped<Dashboard.MeterDetailService>();
services.AddScoped<Dashboard.FlowService>();
services.AddScoped<Backup.ExportService>();
return services;
@@ -68,6 +68,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
});
// Demo flow chain: car charging is a subsection of total house load (Haus → Auto), so the
// electricity flow view shows Haus dividing into Auto + an "Other" remainder.
_db.MeterLinks.Add(new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id });
AddElectricityTariffs(electricity);
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
@@ -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 =>
@@ -0,0 +1,931 @@
// <auto-generated />
using System;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MeterVaultDbContext))]
[Migration("20260714114901_AddMeterLinks")]
partial class AddMeterLinks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("key");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("value");
b.HasKey("Key")
.HasName("pk_app_setting");
b.ToTable("app_setting", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
{
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<short>("Kind")
.HasColumnType("smallint")
.HasColumnName("kind");
b.Property<double>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<short>("Quality")
.HasColumnType("smallint")
.HasColumnName("quality");
b.HasKey("MeterId", "Time", "Kind")
.HasName("pk_consumption");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_consumption_import_batch_id");
b.ToTable("consumption", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ColorHex")
.HasColumnType("text")
.HasColumnName("color_hex");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("name");
b.Property<int>("Sort")
.HasColumnType("integer")
.HasColumnName("sort");
b.HasKey("Id")
.HasName("pk_cost_category");
b.ToTable("cost_category", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CategoryId")
.HasColumnType("integer")
.HasColumnName("category_id");
b.Property<short?>("EnergyTypeId")
.HasColumnType("smallint")
.HasColumnName("energy_type_id");
b.Property<int?>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.HasKey("Id")
.HasName("pk_cost_category_member");
b.HasIndex("CategoryId")
.HasDatabaseName("ix_cost_category_member_category_id");
b.HasIndex("EnergyTypeId")
.HasDatabaseName("ix_cost_category_member_energy_type_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_cost_category_member_meter_id");
b.ToTable("cost_category_member", null, t =>
{
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
});
});
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
{
b.Property<short>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("smallint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
b.Property<string>("BaseUnit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("base_unit");
b.Property<string>("ColorHex")
.HasColumnType("text")
.HasColumnName("color_hex");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DefaultMode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("default_mode");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("display_name");
b.Property<string>("Icon")
.HasColumnType("text")
.HasColumnName("icon");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("key");
b.HasKey("Id")
.HasName("pk_energy_type");
b.HasIndex("Key")
.IsUnique()
.HasDatabaseName("ix_energy_type_key");
b.ToTable("energy_type", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Mapping")
.HasColumnType("jsonb")
.HasColumnName("mapping");
b.Property<DateTimeOffset?>("RevertedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("reverted_at");
b.Property<int>("RowCount")
.HasColumnType("integer")
.HasColumnName("row_count");
b.Property<string>("SourceName")
.HasColumnType("text")
.HasColumnName("source_name");
b.HasKey("Id")
.HasName("pk_import_batch");
b.ToTable("import_batch", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Config")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is_enabled");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("LastStatus")
.HasColumnType("text")
.HasColumnName("last_status");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("name");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_ingestion_endpoint");
b.ToTable("ingestion_endpoint", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<double>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<int?>("CategoryId")
.HasColumnType("integer")
.HasColumnName("category_id");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("currency");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<int?>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<DateOnly>("PeriodEnd")
.HasColumnType("date")
.HasColumnName("period_end");
b.Property<DateOnly>("PeriodStart")
.HasColumnType("date")
.HasColumnName("period_start");
b.HasKey("Id")
.HasName("pk_manual_cost");
b.HasIndex("CategoryId")
.HasDatabaseName("ix_manual_cost_category_id");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_manual_cost_import_batch_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_manual_cost_meter_id");
b.ToTable("manual_cost", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<short>("EnergyTypeId")
.HasColumnType("smallint")
.HasColumnName("energy_type_id");
b.Property<double>("InitialBaseline")
.HasColumnType("double precision")
.HasColumnName("initial_baseline");
b.Property<DateOnly?>("InstalledAt")
.HasColumnType("date")
.HasColumnName("installed_at");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("Location")
.HasColumnType("text")
.HasColumnName("location");
b.Property<string>("Manufacturer")
.HasColumnType("text")
.HasColumnName("manufacturer");
b.Property<string>("Meta")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("meta")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("mode");
b.Property<string>("Model")
.HasColumnType("text")
.HasColumnName("model");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<DateOnly?>("RetiredAt")
.HasColumnType("date")
.HasColumnName("retired_at");
b.Property<string>("SerialNumber")
.HasColumnType("text")
.HasColumnName("serial_number");
b.Property<string>("Unit")
.IsRequired()
.HasColumnType("text")
.HasColumnName("unit");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_meter");
b.HasIndex("EnergyTypeId", "IsActive")
.HasDatabaseName("ix_meter_energy_type_id_is_active");
b.ToTable("meter", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<double?>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("event_type");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<string>("Meta")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("meta")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<double?>("NewValue")
.HasColumnType("double precision")
.HasColumnName("new_value");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<double?>("PrevValue")
.HasColumnType("double precision")
.HasColumnName("prev_value");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<string>("Unit")
.HasColumnType("text")
.HasColumnName("unit");
b.HasKey("Id")
.HasName("pk_meter_event");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_meter_event_import_batch_id");
b.HasIndex("MeterId", "Time")
.HasDatabaseName("ix_meter_event_meter_id_time");
b.ToTable("meter_event", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromMeterId")
.HasColumnType("integer")
.HasColumnName("from_meter_id");
b.Property<int>("ToMeterId")
.HasColumnType("integer")
.HasColumnName("to_meter_id");
b.HasKey("Id")
.HasName("pk_meter_link");
b.HasIndex("ToMeterId")
.HasDatabaseName("ix_meter_link_to_meter_id");
b.HasIndex("FromMeterId", "ToMeterId")
.IsUnique()
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
b.ToTable("meter_link", null, t =>
{
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
});
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Config")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int?>("EndpointId")
.HasColumnType("integer")
.HasColumnName("endpoint_id");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is_enabled");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("LastStatus")
.HasColumnType("text")
.HasColumnName("last_status");
b.Property<double?>("LastValue")
.HasColumnType("double precision")
.HasColumnName("last_value");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<double>("Offset")
.HasColumnType("double precision")
.HasColumnName("offset");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Scale")
.ValueGeneratedOnAdd()
.HasColumnType("double precision")
.HasDefaultValue(1.0)
.HasColumnName("scale");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("source_type");
b.Property<string>("ValueKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("value_kind");
b.HasKey("Id")
.HasName("pk_meter_source");
b.HasIndex("EndpointId")
.HasDatabaseName("ix_meter_source_endpoint_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_meter_source_meter_id");
b.ToTable("meter_source", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
{
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<int>("Flags")
.HasColumnType("integer")
.HasColumnName("flags");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<short>("Quality")
.HasColumnType("smallint")
.HasColumnName("quality");
b.Property<int?>("SourceId")
.HasColumnType("integer")
.HasColumnName("source_id");
b.Property<double>("Value")
.HasColumnType("double precision")
.HasColumnName("value");
b.HasKey("MeterId", "Time")
.HasName("pk_reading");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_reading_import_batch_id");
b.ToTable("reading", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset?>("CachedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("cached_at");
b.Property<double?>("CachedBalance")
.HasColumnType("double precision")
.HasColumnName("cached_balance");
b.Property<string>("Calibration")
.HasColumnType("jsonb")
.HasColumnName("calibration");
b.Property<double>("Capacity")
.HasColumnType("double precision")
.HasColumnName("capacity");
b.Property<double?>("FixedRate")
.HasColumnType("double precision")
.HasColumnName("fixed_rate");
b.Property<double?>("LowThreshold")
.HasColumnType("double precision")
.HasColumnName("low_threshold");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<string>("RateMode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("rate_mode");
b.Property<double?>("ReorderThreshold")
.HasColumnType("double precision")
.HasColumnName("reorder_threshold");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("unit");
b.HasKey("Id")
.HasName("pk_tank");
b.HasIndex("MeterId")
.IsUnique()
.HasDatabaseName("ix_tank_meter_id");
b.ToTable("tank", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Component")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("component");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("currency");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<int?>("ScopeId")
.HasColumnType("integer")
.HasColumnName("scope_id");
b.Property<string>("ScopeType")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("scope_type");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("unit");
b.Property<DateOnly>("ValidFrom")
.HasColumnType("date")
.HasColumnName("valid_from");
b.Property<DateOnly?>("ValidTo")
.HasColumnType("date")
.HasColumnName("valid_to");
b.Property<double>("Value")
.HasColumnType("double precision")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_tariff");
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
b.ToTable("tariff", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_consumption_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
{
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
.WithMany("Members")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
.WithMany()
.HasForeignKey("EnergyTypeId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_cost_category_member_meter_meter_id");
b.Navigation("Category");
});
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
{
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_manual_cost_cost_category_category_id");
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_manual_cost_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
.WithMany("Meters")
.HasForeignKey("EnergyTypeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_meter_energy_type_energy_type_id");
b.Navigation("EnergyType");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_event_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
.WithMany()
.HasForeignKey("FromMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_from_meter_id");
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
.WithMany()
.HasForeignKey("ToMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_to_meter_id");
b.Navigation("FromMeter");
b.Navigation("ToMeter");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
.WithMany()
.HasForeignKey("EndpointId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
.WithMany("Sources")
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_source_meter_meter_id");
b.Navigation("Endpoint");
b.Navigation("Meter");
});
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_reading_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tank_meter_meter_id");
b.Navigation("Meter");
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
{
b.Navigation("Meters");
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.Navigation("Sources");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
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");
}
}
}
@@ -499,6 +499,39 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
b.ToTable("meter_event", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromMeterId")
.HasColumnType("integer")
.HasColumnName("from_meter_id");
b.Property<int>("ToMeterId")
.HasColumnType("integer")
.HasColumnName("to_meter_id");
b.HasKey("Id")
.HasName("pk_meter_link");
b.HasIndex("ToMeterId")
.HasDatabaseName("ix_meter_link_to_meter_id");
b.HasIndex("FromMeterId", "ToMeterId")
.IsUnique()
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
b.ToTable("meter_link", null, t =>
{
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
});
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{
b.Property<int>("Id")
@@ -812,6 +845,27 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_meter_event_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
.WithMany()
.HasForeignKey("FromMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_from_meter_id");
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
.WithMany()
.HasForeignKey("ToMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_to_meter_id");
b.Navigation("FromMeter");
b.Navigation("ToMeter");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")