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
200 lines
8.8 KiB
C#
200 lines
8.8 KiB
C#
using MeterVault.Core.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MeterVault.Infrastructure.Persistence;
|
|
|
|
/// <summary>
|
|
/// The EF Core context owning all relational tables. TimescaleDB-specific objects
|
|
/// (hypertables, compression, continuous aggregates) are created via raw SQL in the
|
|
/// migrations — they are not expressible through the model builder (SDD §5.3).
|
|
/// Snake_case table/column naming is applied at the options level
|
|
/// (<c>UseSnakeCaseNamingConvention</c>) so it matches the SDD schema.
|
|
/// </summary>
|
|
public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> options)
|
|
: DbContext(options)
|
|
{
|
|
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>();
|
|
public DbSet<Tank> Tanks => Set<Tank>();
|
|
public DbSet<Tariff> Tariffs => Set<Tariff>();
|
|
public DbSet<CostCategory> CostCategories => Set<CostCategory>();
|
|
public DbSet<CostCategoryMember> CostCategoryMembers => Set<CostCategoryMember>();
|
|
public DbSet<ManualCost> ManualCosts => Set<ManualCost>();
|
|
public DbSet<IngestionEndpoint> IngestionEndpoints => Set<IngestionEndpoint>();
|
|
public DbSet<ImportBatch> ImportBatches => Set<ImportBatch>();
|
|
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder b)
|
|
{
|
|
b.HasPostgresExtension("timescaledb");
|
|
|
|
b.Entity<EnergyType>(e =>
|
|
{
|
|
e.ToTable("energy_type");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.Id).UseIdentityByDefaultColumn();
|
|
e.Property(x => x.Key).HasMaxLength(64);
|
|
e.Property(x => x.DisplayName).HasMaxLength(128);
|
|
e.Property(x => x.BaseUnit).HasMaxLength(16);
|
|
e.Property(x => x.DefaultMode).HasConversion<string>().HasMaxLength(32);
|
|
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
|
e.HasIndex(x => x.Key).IsUnique();
|
|
});
|
|
|
|
b.Entity<Meter>(e =>
|
|
{
|
|
e.ToTable("meter");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.Mode).HasConversion<string>().HasMaxLength(32);
|
|
e.Property(x => x.Meta).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
|
e.Property(x => x.IsActive).HasDefaultValue(true);
|
|
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
|
e.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
|
|
e.HasOne(x => x.EnergyType).WithMany(x => x.Meters)
|
|
.HasForeignKey(x => x.EnergyTypeId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasIndex(x => new { x.EnergyTypeId, x.IsActive });
|
|
});
|
|
|
|
b.Entity<MeterSource>(e =>
|
|
{
|
|
e.ToTable("meter_source");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.SourceType).HasConversion<string>().HasMaxLength(32);
|
|
e.Property(x => x.ValueKind).HasConversion<string>().HasMaxLength(16);
|
|
e.Property(x => x.Config).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
|
e.Property(x => x.Scale).HasDefaultValue(1d);
|
|
e.HasOne(x => x.Meter).WithMany(x => x.Sources)
|
|
.HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne(x => x.Endpoint).WithMany()
|
|
.HasForeignKey(x => x.EndpointId).OnDelete(DeleteBehavior.SetNull);
|
|
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 =>
|
|
{
|
|
e.ToTable("reading");
|
|
e.HasKey(x => new { x.MeterId, x.Time });
|
|
e.Property(x => x.Quality).HasConversion<short>();
|
|
e.Property(x => x.Flags).HasConversion<int>();
|
|
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasIndex(x => x.ImportBatchId);
|
|
});
|
|
|
|
// Hypertable — (meter_id, time, kind) PK.
|
|
b.Entity<Consumption>(e =>
|
|
{
|
|
e.ToTable("consumption");
|
|
e.HasKey(x => new { x.MeterId, x.Time, x.Kind });
|
|
e.Property(x => x.Kind).HasConversion<short>();
|
|
e.Property(x => x.Quality).HasConversion<short>();
|
|
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasIndex(x => x.ImportBatchId);
|
|
});
|
|
|
|
b.Entity<MeterEvent>(e =>
|
|
{
|
|
e.ToTable("meter_event");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.EventType).HasConversion<string>().HasMaxLength(32);
|
|
e.Property(x => x.Meta).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
|
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasIndex(x => new { x.MeterId, x.Time });
|
|
e.HasIndex(x => x.ImportBatchId);
|
|
});
|
|
|
|
b.Entity<Tank>(e =>
|
|
{
|
|
e.ToTable("tank");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.RateMode).HasConversion<string>().HasMaxLength(16);
|
|
e.Property(x => x.Unit).HasMaxLength(16);
|
|
e.Property(x => x.Calibration).HasColumnType("jsonb");
|
|
e.HasOne(x => x.Meter).WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasIndex(x => x.MeterId).IsUnique();
|
|
});
|
|
|
|
b.Entity<Tariff>(e =>
|
|
{
|
|
e.ToTable("tariff");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.ScopeType).HasConversion<string>().HasMaxLength(16);
|
|
e.Property(x => x.Component).HasConversion<string>().HasMaxLength(16);
|
|
e.Property(x => x.Unit).HasMaxLength(16);
|
|
e.Property(x => x.Currency).HasMaxLength(8);
|
|
e.HasIndex(x => new { x.ScopeType, x.ScopeId, x.Component, x.ValidFrom });
|
|
});
|
|
|
|
b.Entity<CostCategory>(e =>
|
|
{
|
|
e.ToTable("cost_category");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.Name).HasMaxLength(128);
|
|
});
|
|
|
|
b.Entity<CostCategoryMember>(e =>
|
|
{
|
|
e.ToTable("cost_category_member");
|
|
e.HasKey(x => x.Id);
|
|
e.HasOne(x => x.Category).WithMany(x => x.Members)
|
|
.HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne<EnergyType>().WithMany().HasForeignKey(x => x.EnergyTypeId).OnDelete(DeleteBehavior.Cascade);
|
|
e.ToTable(t => t.HasCheckConstraint(
|
|
"ck_cost_category_member_target",
|
|
"meter_id IS NOT NULL OR energy_type_id IS NOT NULL"));
|
|
});
|
|
|
|
b.Entity<ManualCost>(e =>
|
|
{
|
|
e.ToTable("manual_cost");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.Currency).HasMaxLength(8);
|
|
e.HasOne<CostCategory>().WithMany().HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.SetNull);
|
|
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.SetNull);
|
|
e.HasIndex(x => x.ImportBatchId);
|
|
});
|
|
|
|
b.Entity<IngestionEndpoint>(e =>
|
|
{
|
|
e.ToTable("ingestion_endpoint");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.Type).HasConversion<string>().HasMaxLength(32);
|
|
e.Property(x => x.Name).HasMaxLength(128);
|
|
e.Property(x => x.Config).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
|
});
|
|
|
|
b.Entity<ImportBatch>(e =>
|
|
{
|
|
e.ToTable("import_batch");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.Mapping).HasColumnType("jsonb");
|
|
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
|
});
|
|
|
|
b.Entity<AppSetting>(e =>
|
|
{
|
|
e.ToTable("app_setting");
|
|
e.HasKey(x => x.Key);
|
|
e.Property(x => x.Key).HasMaxLength(128);
|
|
e.Property(x => x.Value).HasColumnType("jsonb");
|
|
});
|
|
}
|
|
}
|