M0: scaffold solution, EF+Timescale schema, /healthz

Five-project Clean Architecture solution (Core/Infrastructure/App + Core.Tests/
Integration.Tests) on .NET 10 with central package management, snake_case EF mapping,
and shared build/style config.

- Full domain entity set + EF DbContext for the SDD §5.3 schema (singular table names).
- InitialSchema migration (relational) + TimescaleHypertables migration (raw SQL:
  create_hypertable + compression on reading, hypertable on consumption).
- App wiring: Serilog (actually wired, unlike MQTTower), DbContext, migrate-on-startup,
  /healthz. Serves plain HTTP behind a reverse proxy (no HTTPS redirect).
- deploy/Dockerfile (2-stage, ICU-capable) + docker-compose (app + timescaledb).
- Integration.Tests: shared TimescaleFixture (Testcontainers) — migrations, hypertables,
  compression policy, and /healthz all verified green (4/4).

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 11:02:21 +02:00
commit 48a7f5a825
65 changed files with 5896 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
<ImportMap />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@@ -0,0 +1,9 @@
@inherits LayoutComponentBase
@Body
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@@ -0,0 +1,20 @@
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
+36
View File
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
+7
View File
@@ -0,0 +1,7 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
+5
View File
@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
+6
View File
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
+11
View File
@@ -0,0 +1,11 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MeterVault.App
@using MeterVault.App.Components
@using MeterVault.App.Components.Layout
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<UserSecretsId>metervault-app</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure\MeterVault.Infrastructure.csproj" />
</ItemGroup>
</Project>
+85
View File
@@ -0,0 +1,85 @@
using MeterVault.App.Components;
using MeterVault.Infrastructure;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console());
builder.Services.Configure<MeterVaultOptions>(
builder.Configuration.GetSection(MeterVaultOptions.SectionName));
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
builder.Services.AddMeterVaultInfrastructure(connectionString);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
await MigrateDatabaseAsync(app).ConfigureAwait(false);
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseSerilogRequestLogging();
// No HTTPS redirection: the app serves plain HTTP (port 8080) behind a reverse proxy
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
await app.RunAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Fatal(ex, "MeterVault terminated unexpectedly");
throw;
}
finally
{
await Log.CloseAndFlushAsync().ConfigureAwait(false);
}
static async Task MigrateDatabaseAsync(WebApplication app)
{
var options = app.Configuration
.GetSection(MeterVaultOptions.SectionName)
.Get<MeterVaultOptions>() ?? new MeterVaultOptions();
if (!options.RunMigrationsAtStartup)
{
return;
}
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
await db.Database.MigrateAsync().ConfigureAwait(false);
Log.Information("Database migrations applied");
}
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
public partial class Program;
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5221",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7231;http://localhost:5221",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault"
},
"MeterVault": {
"TimeZone": "Europe/Berlin",
"Currency": "EUR",
"Locale": "en",
"RunMigrationsAtStartup": true,
"RawRetentionDays": 1095
}
}
+38
View File
@@ -0,0 +1,38 @@
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
+13
View File
@@ -0,0 +1,13 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A single key/JSON application setting: currency, locale, timezone, retention, fallbacks
/// (SDD §5.3 <c>app_setting</c>).
/// </summary>
public sealed class AppSetting
{
public required string Key { get; set; }
/// <summary>JSON (jsonb) value.</summary>
public required string Value { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A normalized, append-only consumption/generation delta in the base unit
/// (SDD §5.3 <c>consumption</c>). Backed by a TimescaleDB hypertable.
/// The <see cref="Time"/> marks the end of the interval this delta covers.
/// </summary>
public sealed class Consumption
{
public DateTimeOffset Time { get; set; }
public int MeterId { get; set; }
/// <summary>Consumption(+) or generation(+) in the meter's base unit. May be negative (savings, grid balance).</summary>
public double Amount { get; set; }
public ConsumptionKind Kind { get; set; }
public ReadingQuality Quality { get; set; }
/// <summary>Provenance batch when derived from an import (revert support).</summary>
public int? ImportBatchId { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A reporting group (Heizung / Strom / Wasser / Pool …) that rolls up cost across one or
/// more meters/energy types (SDD §5.3 <c>cost_category</c>). Categories are decoupled from
/// energy types: "Heizung" may be fed by oil today, a heat pump tomorrow.
/// </summary>
public sealed class CostCategory
{
public int Id { get; set; }
public required string Name { get; set; }
public string? ColorHex { get; set; }
public int Sort { get; set; }
public ICollection<CostCategoryMember> Members { get; } = new List<CostCategoryMember>();
}
+18
View File
@@ -0,0 +1,18 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// Maps a meter or an entire energy type into a <see cref="CostCategory"/>
/// (SDD §5.3 <c>cost_category_member</c>). At least one of the two targets must be set.
/// </summary>
public sealed class CostCategoryMember
{
public int Id { get; set; }
public int CategoryId { get; set; }
public CostCategory? Category { get; set; }
public int? MeterId { get; set; }
public short? EnergyTypeId { get; set; }
}
+28
View File
@@ -0,0 +1,28 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A user-defined category of measurement (electricity, water, heating oil, gas, …).
/// Ships with sensible defaults but nothing is hardcoded (SDD FR-1).
/// </summary>
public sealed class EnergyType
{
public short Id { get; set; }
/// <summary>Stable machine key, e.g. 'electricity', 'water', 'heating_oil'.</summary>
public required string Key { get; set; }
public required string DisplayName { get; set; }
/// <summary>Base unit consumption is normalized to: 'kWh', 'm3', 'L', 'h'.</summary>
public required string BaseUnit { get; set; }
public MeterMode DefaultMode { get; set; }
public string? Icon { get; set; }
public string? ColorHex { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public ICollection<Meter> Meters { get; } = new List<Meter>();
}
+121
View File
@@ -0,0 +1,121 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// How a meter's raw readings become normalized consumption (SDD §5.2).
/// Stored as text in the database via a value converter.
/// </summary>
public enum MeterMode
{
/// <summary>Monotonic register (electricity house/grid/EV, water). Consumption = Δ register, with swaps/resets.</summary>
CumulativeCounter,
/// <summary>Monotonic generation register (PV). Δ register → generation.</summary>
GenerationCounter,
/// <summary>Cumulative operating hours (burner). Δ hours × rate (fixed or empirical) → consumption.</summary>
RuntimeCounter,
/// <summary>Tank/bottle with deliveries + level. Deliveries add; usage from level-Δ and/or runtime×rate; forecast to empty.</summary>
ConsumableBalance,
/// <summary>Source already reports increments. The value is the increment.</summary>
DirectDelta,
/// <summary>Power/flow sensor (schema-supported; worker deferred post-v1). Integrate rate over time.</summary>
InstantRate,
/// <summary>Computed from other meters via a user-defined expression.</summary>
Virtual,
}
/// <summary>Whether a normalized amount is drawn (consumption) or produced (generation).</summary>
public enum ConsumptionKind : short
{
Consumption = 0,
Generation = 1,
}
/// <summary>Provenance/confidence of a reading or consumption row.</summary>
public enum ReadingQuality : short
{
Measured = 0,
Estimated = 1,
Manual = 2,
Imported = 3,
Interpolated = 4,
}
/// <summary>Bitmask annotations on a reading/consumption delta.</summary>
[Flags]
public enum ReadingFlags
{
None = 0,
CounterReset = 1,
MeterSwap = 2,
Anomaly = 4,
}
/// <summary>Discrete meter lifecycle/correction events (SDD meter_event.event_type).</summary>
public enum MeterEventType
{
MeterSwap,
CounterReset,
Delivery,
TankLevel,
Correction,
Note,
}
/// <summary>Where a meter_source pulls values from.</summary>
public enum SourceType
{
Mqtt,
Tasmota,
HomeAssistant,
Manual,
Import,
Virtual,
}
/// <summary>What kind of quantity a source reports (drives normalization).</summary>
public enum SourceValueKind
{
Register,
Delta,
Rate,
Level,
Runtime,
}
/// <summary>Scope a tariff applies to.</summary>
public enum TariffScope
{
Global,
EnergyType,
Meter,
}
/// <summary>Which price component a tariff row carries.</summary>
public enum TariffComponent
{
UnitPrice,
BasePrice,
FeedIn,
Bonus,
Discount,
Tax,
}
/// <summary>How a tank's runtime→consumption rate is derived.</summary>
public enum TankRateMode
{
Fixed,
Empirical,
}
/// <summary>Type of an ingestion endpoint (broker / HA connection).</summary>
public enum EndpointType
{
MqttBroker,
HomeAssistant,
}
+22
View File
@@ -0,0 +1,22 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// Provenance and revert handle for a CSV/manual bulk load (SDD §5.3 <c>import_batch</c>, FR-6).
/// Every reading/consumption/event/manual-cost row it produced carries this batch id so the
/// whole import can be reverted.
/// </summary>
public sealed class ImportBatch
{
public int Id { get; set; }
public string? SourceName { get; set; }
/// <summary>JSON (jsonb) snapshot of the mapping profile used.</summary>
public string? Mapping { get; set; }
public int RowCount { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? RevertedAt { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A broker / Home Assistant connection config (SDD §5.3 <c>ingestion_endpoint</c>).
/// Secrets are stored by reference only (env var name / Docker secret path), never as plaintext.
/// </summary>
public sealed class IngestionEndpoint
{
public int Id { get; set; }
public EndpointType Type { get; set; }
public required string Name { get; set; }
/// <summary>JSON (jsonb): host/port/tls/base_url; secrets by reference only.</summary>
public string Config { get; set; } = "{}";
public bool IsEnabled { get; set; } = true;
public string? LastStatus { get; set; }
public DateTimeOffset? LastSeenAt { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A flat cost with no meter, e.g. "Pool Betrieb" (SDD §5.3 <c>manual_cost</c>, FR-10).
/// Attributed to a category and/or meter over a period.
/// </summary>
public sealed class ManualCost
{
public int Id { get; set; }
public int? CategoryId { get; set; }
public int? MeterId { get; set; }
public DateOnly PeriodStart { get; set; }
public DateOnly PeriodEnd { get; set; }
public double Amount { get; set; }
public string Currency { get; set; } = "EUR";
public string? Notes { get; set; }
public int? ImportBatchId { get; set; }
}
+50
View File
@@ -0,0 +1,50 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A metering "device" bound to an energy type and a measurement mode (SDD FR-2).
/// Multiple meters per type are first-class (the reference data has five electricity meters).
/// </summary>
public sealed class Meter
{
public int Id { get; set; }
public required string Name { get; set; }
public short EnergyTypeId { get; set; }
public EnergyType? EnergyType { get; set; }
public MeterMode Mode { get; set; }
/// <summary>Unit of the raw readings; defaults from the energy type's base unit.</summary>
public required string Unit { get; set; }
public string? Location { get; set; }
public string? SerialNumber { get; set; }
public string? Model { get; set; }
public string? Manufacturer { get; set; }
public DateOnly? InstalledAt { get; set; }
public DateOnly? RetiredAt { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>
/// Register baseline for a newly installed meter (SDD §7.1 first-reading behaviour).
/// Default 0 reproduces the spreadsheet's month-one full-register consumption.
/// </summary>
public double InitialBaseline { get; set; }
/// <summary>Free-form JSON (jsonb): rate config, virtual expression, tank ref, etc.</summary>
public string Meta { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public ICollection<MeterSource> Sources { get; } = new List<MeterSource>();
}
+34
View File
@@ -0,0 +1,34 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A discrete meter event: swap, reset, delivery, tank level, correction, or note
/// (SDD §5.3 <c>meter_event</c>). Drives normalization across boundaries the raw
/// register math cannot express on its own (e.g. the water meter swap …861→2).
/// </summary>
public sealed class MeterEvent
{
public int Id { get; set; }
public int MeterId { get; set; }
public DateTimeOffset Time { get; set; }
public MeterEventType EventType { get; set; }
/// <summary>Delivery litres / correction value / explicit consumption override for a swap month.</summary>
public double? Amount { get; set; }
/// <summary>Swap: the old register's final value before replacement.</summary>
public double? PrevValue { get; set; }
/// <summary>Swap: the new register's initial value after replacement.</summary>
public double? NewValue { get; set; }
public string? Unit { get; set; }
public string? Notes { get; set; }
public string Meta { get; set; } = "{}";
public int? ImportBatchId { get; set; }
}
+39
View File
@@ -0,0 +1,39 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// An ingest binding for a meter: MQTT topic, Tasmota field, HA entity, manual, import,
/// or virtual (formula). A meter can have 0..n sources (SDD FR-3).
/// </summary>
public sealed class MeterSource
{
public int Id { get; set; }
public int MeterId { get; set; }
public Meter? Meter { get; set; }
public SourceType SourceType { get; set; }
public int? EndpointId { get; set; }
public IngestionEndpoint? Endpoint { get; set; }
/// <summary>JSON (jsonb): topic / field path / entity_id / expression / poll interval.</summary>
public string Config { get; set; } = "{}";
public SourceValueKind ValueKind { get; set; }
public double Scale { get; set; } = 1;
public double Offset { get; set; }
public int Priority { get; set; }
public bool IsEnabled { get; set; } = true;
public DateTimeOffset? LastSeenAt { get; set; }
public double? LastValue { get; set; }
public string? LastStatus { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A raw, immutable, timestamped meter value — the audit truth (SDD §5.3 <c>reading</c>).
/// Backed by a TimescaleDB hypertable with composite key (meter_id, time).
/// The value is a register / level / hours / rate in the meter's own unit.
/// </summary>
public sealed class Reading
{
public DateTimeOffset Time { get; set; }
public int MeterId { get; set; }
public double Value { get; set; }
public int? SourceId { get; set; }
public ReadingQuality Quality { get; set; }
public ReadingFlags Flags { get; set; }
/// <summary>Provenance batch when this row came from a CSV/bulk import (revert support).</summary>
public int? ImportBatchId { get; set; }
}
+35
View File
@@ -0,0 +1,35 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A consumable store attached to a meter: capacity, cm→litre calibration, thresholds,
/// and a cached balance (SDD §5.3 <c>tank</c>). Generalizes "not limited to oil": any
/// consumable drawn from a store and/or consumed proportionally to a runtime signal.
/// </summary>
public sealed class Tank
{
public int Id { get; set; }
public int MeterId { get; set; }
public Meter? Meter { get; set; }
public double Capacity { get; set; }
public string Unit { get; set; } = "L";
/// <summary>JSON (jsonb): cm→litre curve or geometry for physical level readings.</summary>
public string? Calibration { get; set; }
public TankRateMode RateMode { get; set; } = TankRateMode.Empirical;
/// <summary>Litres per runtime-hour when <see cref="RateMode"/> is Fixed (nozzle spec).</summary>
public double? FixedRate { get; set; }
public double? LowThreshold { get; set; }
public double? ReorderThreshold { get; set; }
public double? CachedBalance { get; set; }
public DateTimeOffset? CachedAt { get; set; }
}
+30
View File
@@ -0,0 +1,30 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A price component with a validity range (price history), scoped globally, per energy
/// type, or per meter (SDD §5.3 <c>tariff</c>, FR-9). <see cref="ValidTo"/> null = open-ended.
/// </summary>
public sealed class Tariff
{
public int Id { get; set; }
public TariffScope ScopeType { get; set; }
/// <summary>energy_type.id or meter.id; null for global scope.</summary>
public int? ScopeId { get; set; }
public TariffComponent Component { get; set; }
public double Value { get; set; }
/// <summary>e.g. 'EUR/kWh', 'EUR/m3', 'EUR/100L', 'EUR/month'.</summary>
public required string Unit { get; set; }
public string Currency { get; set; } = "EUR";
public DateOnly ValidFrom { get; set; }
public DateOnly? ValidTo { get; set; }
public string? Notes { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Pure domain: zero external packages so Core.Tests run with no Docker/infra. -->
<ItemGroup>
<InternalsVisibleTo Include="MeterVault.Core.Tests" />
</ItemGroup>
</Project>
+22
View File
@@ -0,0 +1,22 @@
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MeterVault.Infrastructure;
/// <summary>Composition root for the infrastructure layer (persistence, ingestion, import).</summary>
public static class DependencyInjection
{
public static IServiceCollection AddMeterVaultInfrastructure(
this IServiceCollection services,
string connectionString)
{
services.AddDbContext<MeterVaultDbContext>(options =>
options
.UseNpgsql(connectionString, npgsql =>
npgsql.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
.UseSnakeCaseNamingConvention());
return services;
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="EFCore.NamingConventions" />
<PackageReference Include="Dapper" />
<PackageReference Include="CsvHelper" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\MeterVault.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MeterVault.Integration.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,24 @@
namespace MeterVault.Infrastructure.Options;
/// <summary>
/// Root application options, bound from the "MeterVault" configuration section.
/// Secrets (broker tokens etc.) live on individual endpoints by reference, not here.
/// </summary>
public sealed class MeterVaultOptions
{
public const string SectionName = "MeterVault";
/// <summary>IANA timezone used for local-midnight bucketing and display (SDD §10).</summary>
public string TimeZone { get; set; } = "Europe/Berlin";
public string Currency { get; set; } = "EUR";
/// <summary>Default UI locale: 'en' (OSS default) or 'de'.</summary>
public string Locale { get; set; } = "en";
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
public bool RunMigrationsAtStartup { get; set; } = true;
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
public int RawRetentionDays { get; set; } = 1095;
}
@@ -0,0 +1,188 @@
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<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);
});
// 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");
});
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace MeterVault.Infrastructure.Persistence;
/// <summary>
/// Design-time factory so <c>dotnet ef migrations add … -p src/Infrastructure -s src/App</c>
/// can build the context without booting the whole app. The connection string here is used
/// only by the EF tooling; the running app configures its own from settings.
/// </summary>
public sealed class MeterVaultDbContextFactory : IDesignTimeDbContextFactory<MeterVaultDbContext>
{
public MeterVaultDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("METERVAULT_DESIGN_CONNECTION")
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
var options = new DbContextOptionsBuilder<MeterVaultDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
.UseSnakeCaseNamingConvention()
.Options;
return new MeterVaultDbContext(options);
}
}
@@ -0,0 +1,877 @@
// <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("20260713085626_InitialSchema")]
partial class InitialSchema
{
/// <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.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.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,485 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:timescaledb", ",,");
migrationBuilder.CreateTable(
name: "app_setting",
columns: table => new
{
key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
value = table.Column<string>(type: "jsonb", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_app_setting", x => x.key);
});
migrationBuilder.CreateTable(
name: "cost_category",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
color_hex = table.Column<string>(type: "text", nullable: true),
sort = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_cost_category", x => x.id);
});
migrationBuilder.CreateTable(
name: "energy_type",
columns: table => new
{
id = table.Column<short>(type: "smallint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
display_name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
base_unit = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
default_mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
icon = table.Column<string>(type: "text", nullable: true),
color_hex = table.Column<string>(type: "text", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_energy_type", x => x.id);
});
migrationBuilder.CreateTable(
name: "import_batch",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
source_name = table.Column<string>(type: "text", nullable: true),
mapping = table.Column<string>(type: "jsonb", nullable: true),
row_count = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
reverted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_import_batch", x => x.id);
});
migrationBuilder.CreateTable(
name: "ingestion_endpoint",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
config = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
is_enabled = table.Column<bool>(type: "boolean", nullable: false),
last_status = table.Column<string>(type: "text", nullable: true),
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_ingestion_endpoint", x => x.id);
});
migrationBuilder.CreateTable(
name: "tariff",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
scope_type = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
scope_id = table.Column<int>(type: "integer", nullable: true),
component = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
value = table.Column<double>(type: "double precision", nullable: false),
unit = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
valid_from = table.Column<DateOnly>(type: "date", nullable: false),
valid_to = table.Column<DateOnly>(type: "date", nullable: true),
notes = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_tariff", x => x.id);
});
migrationBuilder.CreateTable(
name: "meter",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "text", nullable: false),
energy_type_id = table.Column<short>(type: "smallint", nullable: false),
mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
unit = table.Column<string>(type: "text", nullable: false),
location = table.Column<string>(type: "text", nullable: true),
serial_number = table.Column<string>(type: "text", nullable: true),
model = table.Column<string>(type: "text", nullable: true),
manufacturer = table.Column<string>(type: "text", nullable: true),
installed_at = table.Column<DateOnly>(type: "date", nullable: true),
retired_at = table.Column<DateOnly>(type: "date", nullable: true),
is_active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
initial_baseline = table.Column<double>(type: "double precision", nullable: false),
meta = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_meter", x => x.id);
table.ForeignKey(
name: "fk_meter_energy_type_energy_type_id",
column: x => x.energy_type_id,
principalTable: "energy_type",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "consumption",
columns: table => new
{
time = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
meter_id = table.Column<int>(type: "integer", nullable: false),
kind = table.Column<short>(type: "smallint", nullable: false),
amount = table.Column<double>(type: "double precision", nullable: false),
quality = table.Column<short>(type: "smallint", nullable: false),
import_batch_id = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_consumption", x => new { x.meter_id, x.time, x.kind });
table.ForeignKey(
name: "fk_consumption_meter_meter_id",
column: x => x.meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "cost_category_member",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
category_id = table.Column<int>(type: "integer", nullable: false),
meter_id = table.Column<int>(type: "integer", nullable: true),
energy_type_id = table.Column<short>(type: "smallint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_cost_category_member", x => x.id);
table.CheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
table.ForeignKey(
name: "fk_cost_category_member_cost_category_category_id",
column: x => x.category_id,
principalTable: "cost_category",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_cost_category_member_energy_type_energy_type_id",
column: x => x.energy_type_id,
principalTable: "energy_type",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_cost_category_member_meter_meter_id",
column: x => x.meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "manual_cost",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
category_id = table.Column<int>(type: "integer", nullable: true),
meter_id = table.Column<int>(type: "integer", nullable: true),
period_start = table.Column<DateOnly>(type: "date", nullable: false),
period_end = table.Column<DateOnly>(type: "date", nullable: false),
amount = table.Column<double>(type: "double precision", nullable: false),
currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
notes = table.Column<string>(type: "text", nullable: true),
import_batch_id = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_manual_cost", x => x.id);
table.ForeignKey(
name: "fk_manual_cost_cost_category_category_id",
column: x => x.category_id,
principalTable: "cost_category",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_manual_cost_meter_meter_id",
column: x => x.meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "meter_event",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
meter_id = table.Column<int>(type: "integer", nullable: false),
time = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
event_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
amount = table.Column<double>(type: "double precision", nullable: true),
prev_value = table.Column<double>(type: "double precision", nullable: true),
new_value = table.Column<double>(type: "double precision", nullable: true),
unit = table.Column<string>(type: "text", nullable: true),
notes = table.Column<string>(type: "text", nullable: true),
meta = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
import_batch_id = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_meter_event", x => x.id);
table.ForeignKey(
name: "fk_meter_event_meter_meter_id",
column: x => x.meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "meter_source",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
meter_id = table.Column<int>(type: "integer", nullable: false),
source_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
endpoint_id = table.Column<int>(type: "integer", nullable: true),
config = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
value_kind = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
scale = table.Column<double>(type: "double precision", nullable: false, defaultValue: 1.0),
offset = table.Column<double>(type: "double precision", nullable: false),
priority = table.Column<int>(type: "integer", nullable: false),
is_enabled = table.Column<bool>(type: "boolean", nullable: false),
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
last_value = table.Column<double>(type: "double precision", nullable: true),
last_status = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_meter_source", x => x.id);
table.ForeignKey(
name: "fk_meter_source_ingestion_endpoints_endpoint_id",
column: x => x.endpoint_id,
principalTable: "ingestion_endpoint",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_meter_source_meter_meter_id",
column: x => x.meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "reading",
columns: table => new
{
time = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
meter_id = table.Column<int>(type: "integer", nullable: false),
value = table.Column<double>(type: "double precision", nullable: false),
source_id = table.Column<int>(type: "integer", nullable: true),
quality = table.Column<short>(type: "smallint", nullable: false),
flags = table.Column<int>(type: "integer", nullable: false),
import_batch_id = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_reading", x => new { x.meter_id, x.time });
table.ForeignKey(
name: "fk_reading_meter_meter_id",
column: x => x.meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "tank",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
meter_id = table.Column<int>(type: "integer", nullable: false),
capacity = table.Column<double>(type: "double precision", nullable: false),
unit = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
calibration = table.Column<string>(type: "jsonb", nullable: true),
rate_mode = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
fixed_rate = table.Column<double>(type: "double precision", nullable: true),
low_threshold = table.Column<double>(type: "double precision", nullable: true),
reorder_threshold = table.Column<double>(type: "double precision", nullable: true),
cached_balance = table.Column<double>(type: "double precision", nullable: true),
cached_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_tank", x => x.id);
table.ForeignKey(
name: "fk_tank_meter_meter_id",
column: x => x.meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_consumption_import_batch_id",
table: "consumption",
column: "import_batch_id");
migrationBuilder.CreateIndex(
name: "ix_cost_category_member_category_id",
table: "cost_category_member",
column: "category_id");
migrationBuilder.CreateIndex(
name: "ix_cost_category_member_energy_type_id",
table: "cost_category_member",
column: "energy_type_id");
migrationBuilder.CreateIndex(
name: "ix_cost_category_member_meter_id",
table: "cost_category_member",
column: "meter_id");
migrationBuilder.CreateIndex(
name: "ix_energy_type_key",
table: "energy_type",
column: "key",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_manual_cost_category_id",
table: "manual_cost",
column: "category_id");
migrationBuilder.CreateIndex(
name: "ix_manual_cost_import_batch_id",
table: "manual_cost",
column: "import_batch_id");
migrationBuilder.CreateIndex(
name: "ix_manual_cost_meter_id",
table: "manual_cost",
column: "meter_id");
migrationBuilder.CreateIndex(
name: "ix_meter_energy_type_id_is_active",
table: "meter",
columns: new[] { "energy_type_id", "is_active" });
migrationBuilder.CreateIndex(
name: "ix_meter_event_import_batch_id",
table: "meter_event",
column: "import_batch_id");
migrationBuilder.CreateIndex(
name: "ix_meter_event_meter_id_time",
table: "meter_event",
columns: new[] { "meter_id", "time" });
migrationBuilder.CreateIndex(
name: "ix_meter_source_endpoint_id",
table: "meter_source",
column: "endpoint_id");
migrationBuilder.CreateIndex(
name: "ix_meter_source_meter_id",
table: "meter_source",
column: "meter_id");
migrationBuilder.CreateIndex(
name: "ix_reading_import_batch_id",
table: "reading",
column: "import_batch_id");
migrationBuilder.CreateIndex(
name: "ix_tank_meter_id",
table: "tank",
column: "meter_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tariff_scope_type_scope_id_component_valid_from",
table: "tariff",
columns: new[] { "scope_type", "scope_id", "component", "valid_from" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "app_setting");
migrationBuilder.DropTable(
name: "consumption");
migrationBuilder.DropTable(
name: "cost_category_member");
migrationBuilder.DropTable(
name: "import_batch");
migrationBuilder.DropTable(
name: "manual_cost");
migrationBuilder.DropTable(
name: "meter_event");
migrationBuilder.DropTable(
name: "meter_source");
migrationBuilder.DropTable(
name: "reading");
migrationBuilder.DropTable(
name: "tank");
migrationBuilder.DropTable(
name: "tariff");
migrationBuilder.DropTable(
name: "cost_category");
migrationBuilder.DropTable(
name: "ingestion_endpoint");
migrationBuilder.DropTable(
name: "meter");
migrationBuilder.DropTable(
name: "energy_type");
}
}
}
@@ -0,0 +1,877 @@
// <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("20260713085642_TimescaleHypertables")]
partial class TimescaleHypertables
{
/// <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.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.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,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
/// <summary>
/// Converts the raw <c>reading</c> and normalized <c>consumption</c> tables into TimescaleDB
/// hypertables, and enables columnar compression on <c>reading</c> (SDD §5.3, §5.5).
/// These objects are not expressible through EF's model builder, so they live as raw SQL.
/// The tables are freshly created (empty) by the preceding InitialSchema migration, so no
/// data migration is needed. create_hypertable/compression are transaction-safe — unlike the
/// continuous aggregates added in M4, which will require suppressTransaction.
/// </summary>
public partial class TimescaleHypertables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Raw readings: 30-day chunks; the (meter_id, time) PK contains the partition column.
migrationBuilder.Sql(
"SELECT create_hypertable('reading', 'time', chunk_time_interval => INTERVAL '30 days');");
// Columnar compression, segmented by meter (monotonic sensor data compresses ~10-20x).
migrationBuilder.Sql(
"ALTER TABLE reading SET (" +
"timescaledb.compress, " +
"timescaledb.compress_segmentby = 'meter_id', " +
"timescaledb.compress_orderby = 'time DESC');");
migrationBuilder.Sql(
"SELECT add_compression_policy('reading', INTERVAL '30 days');");
// Normalized consumption: 90-day chunks. Kept effectively forever (small), so no compression policy.
migrationBuilder.Sql(
"SELECT create_hypertable('consumption', 'time', chunk_time_interval => INTERVAL '90 days');");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// The hypertables themselves are torn down when InitialSchema.Down drops the tables
// (chunks drop with them). Only the compression policy needs explicit removal.
migrationBuilder.Sql("SELECT remove_compression_policy('reading', if_exists => true);");
}
}
}
@@ -0,0 +1,874 @@
// <auto-generated />
using System;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MeterVaultDbContext))]
partial class MeterVaultDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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.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.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
}
}
}