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
+13
View File
@@ -0,0 +1,13 @@
**/bin/
**/obj/
**/.vs/
**/.idea/
.git/
.github/
docs/
sampledata/
tests/
**/*.user
**/appsettings.*.Local.json
.env
.env.*
+55
View File
@@ -0,0 +1,55 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = crlf
indent_style = space
[*.{cs,csx}]
indent_size = 4
[*.cs]
# Usings
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:suggestion
# Language style
csharp_style_namespace_declarations = file_scoped:warning
csharp_style_prefer_primary_constructors = true:suggestion
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_prefer_braces = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = false:suggestion
# private fields => _camelCase
dotnet_naming_rule.private_fields_underscore.severity = suggestion
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_prefix.capitalization = camel_case
dotnet_naming_style.underscore_prefix.required_prefix = _
# A few analyzer rules relaxed for a homelab app (don't block the build on these)
dotnet_diagnostic.CA1848.severity = none # LoggerMessage delegates — overkill here
dotnet_diagnostic.CA2007.severity = none # ConfigureAwait handled by convention, not enforced
dotnet_diagnostic.CA1515.severity = none # public types in app assemblies are fine
dotnet_diagnostic.CA1711.severity = none # domain names may end in Flags/Enum (e.g. ReadingFlags)
dotnet_diagnostic.CA1720.severity = none # identifiers containing type names (e.g. 'Value')
dotnet_diagnostic.CA1819.severity = none # properties returning arrays (DTOs)
# EF Core generated migrations: don't enforce our code-style opinions on tool output.
[**/Migrations/*.cs]
generated_code = true
dotnet_diagnostic.IDE0161.severity = none
dotnet_diagnostic.IDE0300.severity = none
dotnet_diagnostic.IDE0028.severity = none
dotnet_diagnostic.CA1861.severity = none
[*.{json,yml,yaml,csproj,props,targets}]
indent_size = 2
[*.razor]
indent_size = 4
+46
View File
@@ -0,0 +1,46 @@
# .NET build output
bin/
obj/
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
artifacts/
publish/
*.user
*.userosscache
*.suo
*.dll
*.exe
*.pdb
# Rider / VS / VS Code
.idea/
.vs/
*.sln.iml
# Test / coverage
[Tt]est[Rr]esult*/
coverage*.json
coverage*.xml
coverage*.cobertura.xml
*.trx
# NuGet
*.nupkg
*.snupkg
.nuget/
packages/
# Environment / secrets (never commit)
.env
.env.*
*.env
appsettings.*.Local.json
secrets.json
# OS
.DS_Store
Thumbs.db
+86
View File
@@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
**Status: pre-code.** The repository currently contains only the design spec and reference data — no solution, projects, or build yet. The first coding task is milestone **M0** (scaffold) from the spec.
## Source of truth
`docs/SDD.md` is the authoritative spec and build brief — read it before implementing anything. Key protocol from §0 that governs all work here:
- **Build strictly in milestone order (§12, M0→M7).** Each milestone is independently runnable and testable; do not start Mn+1 until Mn's tests pass.
- **The four CSVs in `sampledata/` are golden fixtures.** Every parsing / consumption / cost rule must reconcile against them (§13). **If a computed number disagrees with the spreadsheet, the spreadsheet wins** unless the discrepancy is a deliberately documented correctness fix.
- When a design decision is ambiguous, check §14 (open questions): if listed, take the stated default and flag it; if not listed, ask before guessing.
- Keep the **domain layer free of infrastructure concerns** (the domain model and DB schema are UI-agnostic by design).
## Committed tech stack (do not re-litigate; see SDD §4.1)
.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion/aggregation · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR.
## Intended project layout (created during M0, per SDD §11)
```
/src/Core domain entities, enums, interfaces, expression evaluator (no infra deps)
/src/Infrastructure EF Core + Npgsql, Dapper repos, Timescale raw-SQL migrations, MQTT/HA clients, CSV importer
/src/App ASP.NET Core host: Blazor Server UI + REST API + hosted workers
/tests/Core.Tests unit: deltas, swaps, tariff resolution, oil rate, CSV parsing
/tests/Integration.Tests Testcontainers (Timescale): ingest→aggregate→cost e2e
/tests/fixtures the 4 reference CSVs + expected outputs
/deploy Dockerfile, docker-compose.yml (app + timescaledb), unraid-template.xml
```
## Commands (apply once M0 has scaffolded the solution)
```powershell
dotnet build # build the solution
dotnet test # all tests (Integration.Tests needs Docker for Testcontainers)
dotnet test tests/Core.Tests # unit tests only (no Docker needed)
dotnet test --filter "FullyQualifiedName~Csv" # a single test / class by name filter
dotnet run --project src/App # run app + workers locally
docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together
```
## Core architecture (the part that spans multiple files)
**Data pipeline — one direction, layered (SDD §4.2, §5, §7):**
```
sources (Tasmota/HA/MQTT/manual/CSV)
→ Ingestion workers write raw `reading` rows (immutable audit truth)
→ Normalization derives append-only `consumption` (deltas in base unit)
→ TimescaleDB continuous aggregates roll consumption to hourly/daily/monthly/yearly
→ Cost engine joins aggregates with time-ranged `tariff`
→ Blazor dashboard + REST API read aggregates + cost views
```
**Invariants that shape everything:**
- **Raw `reading` is immutable audit truth.** Everything derived (consumption, cost, balances, forecasts) is computed on top and must be reproducible. Never mutate readings to fix a derived number.
- **Dashboards and charts read aggregates only — never scan `reading`.** This is what makes 1000 meters × 50 years feasible (§5.5). Raw is kept for a bounded window (default 3y); `consumption` + aggregates are the long-term source of truth.
- **`meter.mode` (measurement mode) is the central abstraction** for how raw readings become consumption (SDD §5.2): `cumulative_counter`, `generation_counter`, `runtime_counter` (Δhours × rate), `consumable_balance` (tank: deliveries usage + forecast), `direct_delta`, `instant_rate`, `virtual` (expression over other meters). New ingestion/normalization logic dispatches on mode.
- **Nothing domain-specific is hardcoded.** Energy types are data. Cost **categories are decoupled from energy types** (Heizung may be oil today, heat-pump tomorrow). PV self-consumption/savings/net are **virtual meters** with user-defined expressions, not special-cased code. Tariffs are time-ranged (price history), scoped global / per-type / per-meter.
**Timescale vs EF split (SDD §5.3):** EF Core migrations own the relational tables. **Timescale-specific DDL — `create_hypertable`, compression policies, continuous aggregates, retention — is not expressible via EF's model builder and must live in raw-SQL migrations.** `reading` and `consumption` are hypertables.
**Time & DST (SDD §10):** store UTC everywhere; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. `ingestion_endpoint.config` holds a *reference* (env var name / Docker secret path) resolved at runtime.
## Reference-data behaviours the code must reproduce (from `sampledata/`)
These CSVs are the German-dialect *Energiebilanz* spreadsheet export and define the minimum feature bar (SDD §2, Appendix A). When writing the importer or normalization, honour:
- **German number dialect:** decimal comma (`180,8244706`), thousands dot (`2.940,19`), trailing-`€` currency (`120,00 €`), **unit suffixes on values** (`411kWh`, `49` cm, `2287` L) — strip and validate.
- **Two date formats:** `Monat YYYY` (German month names, monthly tables) and `DD.MM.YYYY` (event rows).
- **Skip inline summary rows** (`Total`, `Heute`, `Seitbeginn Tage`, `Seit YYYY`) and **all-zero future placeholder rows** (e.g. Dec 2026) — do not ingest them. **Negatives are valid** (savings, grid balance).
- **Water register swaps mid-series** (…861 → 2 → 15): consumption must stay continuous across the boundary via a `meter_swap` event.
- **Electricity has 5 meters** (Haus, Netz, Auto, Solar 1, Solar 2) plus derived columns. Verified relations to reproduce: `Netz Einsparung = Haus Netz`, `Ersparnis = Netz Einsparung × €/kWh`, `Kosten = Verbrauchskosten Ersparnis` — but implement these as **user-definable virtual-meter expressions**, not hardcoded formulas.
- **Heating oil is the versatility stress test:** a consumable/tank model where consumption is derivable two ways — tank-level Δ, or burner runtime × rate (rate `fixed` from nozzle spec, or `empirical` = Δlevel ÷ Δhours). Early rows (19972004) carry deliveries only (no burner hours yet). Includes cm→litre dipstick calibration and forecast-to-empty.
## Git
Remote `origin` is `https://git.finalfactory.de/FinalFactory/MeterVault.git` (default branch `main`).
+24
View File
@@ -0,0 +1,24 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>Default</AnalysisMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- The German-dialect CSV importer parses/formats with explicit CultureInfo("de-DE");
invariant globalization would break it and the container needs ICU. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. -->
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
+37
View File
@@ -0,0 +1,37 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup Label="Data / infrastructure">
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageVersion Include="Dapper" Version="2.1.79" />
<PackageVersion Include="CsvHelper" Version="33.1.0" />
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
</ItemGroup>
<ItemGroup Label="App / UI">
<PackageVersion Include="MudBlazor" Version="9.7.0" />
<PackageVersion Include="Blazor-ApexCharts" Version="6.1.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<ItemGroup Label="Tests">
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageVersion Include="Respawn" Version="7.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
</ItemGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
<Solution>
<Folder Name="/src/" />
<Folder Name="/src/App/">
<Project Path="src/App/MeterVault.App.csproj" />
</Folder>
<Folder Name="/src/Core/">
<Project Path="src/Core/MeterVault.Core.csproj" />
</Folder>
<Folder Name="/src/Infrastructure/">
<Project Path="src/Infrastructure/MeterVault.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/" />
<Folder Name="/tests/Core.Tests/">
<Project Path="tests/Core.Tests/MeterVault.Core.Tests.csproj" />
</Folder>
<Folder Name="/tests/Integration.Tests/">
<Project Path="tests/Integration.Tests/MeterVault.Integration.Tests.csproj" />
</Folder>
</Solution>
+30
View File
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
# Build context is the repository root (see docker-compose.yml: context: ..).
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore first (layer-cached on lockfile-ish inputs).
COPY Directory.Build.props Directory.Packages.props global.json ./
COPY src/Core/MeterVault.Core.csproj src/Core/
COPY src/Infrastructure/MeterVault.Infrastructure.csproj src/Infrastructure/
COPY src/App/MeterVault.App.csproj src/App/
RUN dotnet restore src/App/MeterVault.App.csproj
# Build & publish.
COPY src/ ./src/
RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false
# Debian-based runtime (keeps full ICU — required by the de-DE CSV importer; do not use
# an invariant-globalization or chiselled image that strips ICU).
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish ./
ENV ASPNETCORE_URLS=http://+:8080 \
ASPNETCORE_ENVIRONMENT=Production \
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE 8080
ENTRYPOINT ["dotnet", "MeterVault.App.dll"]
+47
View File
@@ -0,0 +1,47 @@
# MeterVault self-host stack: the Blazor app + TimescaleDB.
# docker compose -f deploy/docker-compose.yml up -d
# Secrets are supplied via environment variables (Section__Key double-underscore mapping)
# with ${VAR:-default} fallbacks — never baked into the image.
services:
db:
image: timescale/timescaledb:2.17.2-pg16
environment:
POSTGRES_DB: metervault
POSTGRES_USER: metervault
POSTGRES_PASSWORD: ${METERVAULT_DB_PASSWORD:-metervault}
volumes:
- metervault_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U metervault -d metervault"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
app:
build:
context: ..
dockerfile: deploy/Dockerfile
image: ${METERVAULT_IMAGE:-metervault/metervault:local}
depends_on:
db:
condition: service_healthy
environment:
ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}"
ASPNETCORE_ENVIRONMENT: Production
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
ports:
- "${METERVAULT_PORT:-8080}:8080"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/healthz || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
volumes:
metervault_db:
+588
View File
@@ -0,0 +1,588 @@
# MeterVault — Software Development Document
> **Working codename:** `MeterVault` (rename freely before publishing). Your existing spreadsheet system is called *Energiebilanz*; for a public GitHub repo an English name reaches more people, but `Energiebilanz` is also fine.
>
> **What this is:** a self-hosted, local-first energy & utility metering platform that pulls meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading with a timestamp, and turns it into cost dashboards. Not limited to electricity/water/oil — energy types are user-defined.
>
> **Status:** design spec, pre-code. This document doubles as the build brief for Claude Code.
---
## 0. How to build this with Claude Code
This document is the source of truth. Suggested working protocol:
1. Drop this file in the repo root as `SDD.md`, and create a `CLAUDE.md` that references it (`See SDD.md for the full spec; work milestone by milestone; do not skip tests`).
2. Build strictly in the **milestone order** in §12. Each milestone is independently runnable and testable — do not start Mn+1 until Mn's tests pass.
3. The **four reference CSVs** (`Energiebilanz_-_*.csv`) are golden fixtures. Every parsing/consumption/cost rule in this doc must be validated against them (§13). If a computed number disagrees with the spreadsheet, the spreadsheet wins unless the discrepancy is documented as a deliberate correctness fix.
4. Prefer small, reviewable PRs per milestone. Keep the domain layer free of infrastructure concerns.
5. When a design decision is ambiguous, check §14 (open questions) — if it's listed, pick the stated default and flag it; if it isn't, ask before guessing.
---
## 1. Vision & scope
### 1.1 Problem
Utility/energy bookkeeping today lives in a hand-maintained Google Sheet (the *Energiebilanz*). It works but: readings are entered manually and monthly, there's no live pull from the sensors that already exist (Tasmota plugs, HA entities), the cost logic is buried in cell formulas, and it doesn't scale to fine-grained data or many meters.
### 1.2 Goals
- **Automatic ingestion** from HA, Tasmota and raw MQTT on a schedule, plus manual entry and CSV import.
- **Every reading timestamped** and preserved (auditable), with a normalized consumption/cost layer on top.
- **Versatile by design:** energy types (electricity, water, heating oil, gas, district heat, pool operation, …) and meters are user-defined, not hardcoded. Multiple meters of the same type are first-class (the reference data has **five** electricity meters).
- **Cost engine** with time-ranged tariffs (price history), per-type and per-category rollups.
- **Neat dashboard:** daily / monthly / yearly cost; period-over-period difference ("what cost more, what cost less"); PV savings; oil/tank forecasting; cost ranking.
- **Scale target:** up to ~1,000 meters, data retained up to 50 years.
- **Self-hosted, zero cloud dependency.** Runs on the existing homelab (Docker on Unraid, or Proxmox LXC).
- **Releasable OSS quality:** clean repo, Docker image, docs, CI, license.
### 1.3 Non-goals (v1)
- Not a smart-meter *reading* device (no P1/SML hardware decoding — that stays in HA/ESPHome/Tasmota upstream; we ingest the resulting values).
- Not a billing/invoicing system for third parties.
- Not multi-tenant SaaS. Single household/instance; optional lightweight multi-user, but not tenant isolation.
- No mobile native app (responsive web is enough).
---
## 2. What the reference data establishes
The four CSVs are the minimum feature bar. Summary of what each proves the app must support:
### 2.1 `Kosten` (cost overview) — monthly
Columns: `Datum, Jahreskosten, Kosten, Heizung, Strom, Wasser, Pool Betrieb`.
→ A **cost-category** rollup: monthly total plus a breakdown per category (Heizung/Strom/Wasser/Pool), and a yearly total (on December rows). "Heizung" is a *category* that may be fed by oil today but gas/heat-pump tomorrow — categories are decoupled from energy types. "Pool Betrieb" can be a **flat manual monthly cost** with no meter.
### 2.2 `Strom_Verbrauch` (electricity) — monthly, rich
Meters (cumulative registers): `Zähler Haus`, `Zähler Netz`, `Zähler Auto` (wallbox/EV), `Zähler Solar 1`, `Zähler Solar 2`**5 electricity meters**.
Derived quantities: `Solar Erzeugung` (generation), `Netz Einsparung` (grid-balance), `Anlage Eigenverbrauch` (self-consumption), `Ersparnis` (savings), `Verbrauchskosten`, `Kosten` (= `Verbrauchskosten Ersparnis`), plus yearly rollups.
Price `€/kWh` with **history** (0.16 → 0.44 → 0.31 → 0.27).
→ Must support: multiple registers per type; **generation** meters; **virtual/derived** meters computed from others via a formula; PV self-consumption & savings; register→consumption deltas; tariff-time-ranged cost. Verified relation: `Netz Einsparung = Haus Netz`, `Ersparnis = Netz Einsparung × €/kWh`, `Kosten = Verbrauchskosten Ersparnis`. The exact formula is user-domain — the app must let users **define** such derived metrics, not hardcode these.
### 2.3 `Wasser` (water) — monthly
`Zähler Wasser, Wasserverbrauch, €/m³, Kosten, Jahreskosten`. The register **swaps mid-series** (…861 → 2 → 15 → …).
→ Must support **meter swaps / counter resets** with consumption continuity across the boundary.
### 2.4 `Heizöl_Verbrauch` (heating oil) — the versatility stress test
Header KPIs: total delivered `64042`, tank size `7000`, consumption per month/day/year.
Table columns: `Betriebststunden` (cumulative burner hours), `Differenz Betrieb` (Δ hours), `Betrieb / Tag`, `Füllstand cm` (manual dipstick), `Tankfüllstand`/`Tank Aktuell` (litres), `Vorhersage`, `Lieferungmenge` (delivery litres), `Differenz Tank` (Δ level = monthly consumption), `Verbrauch / Tag`, `Vorraussichtliches Ende` (predicted empty), `Verbrauch / Betrieb Stunde` (L/h — **empirically derived**, e.g. 1.87, 1.94, 2.92 …), `€/100l`, `Monatskosten`.
Early rows (19972004) only carry **deliveries** (no burner hours — tracking started later).
→ Must support a **consumable/tank** model: deliveries add to a balance; consumption derivable **two ways** — (a) tank-level Δ, (b) burner **runtime × rate** — where the rate can be **fixed** (nozzle spec) or **empirical** (tank Δ ÷ hours Δ); physical level readings (cm) via a tank calibration curve; and a **forecast to empty**. This is the "not limited to oil" generalization: any consumable drawn from a store and/or consumed proportionally to a runtime signal.
### 2.5 Cross-cutting data facts (drive the CSV importer, §Appendix A)
- Decimal separator is **comma**; thousands separator **dot**; currency like `2.940,19 €`.
- Values carry **unit suffixes**: `411kWh`, `49` cm, `2287` L.
- Two date formats: `"September 2022"` (month tables) and `10.06.1997` / `13.07.2026` (`DD.MM.YYYY`, event rows).
- **Summary rows** exist inline (`Total`, `Heute`, `Seitbeginn Tage`, `Seit 2023`) and must be skipped, not ingested.
- **Placeholder future rows** (Dec 2026 all-zero) must be treated as no-data.
- Negative values are valid (savings, grid balance).
---
## 3. Functional requirements
| ID | Requirement |
|----|-------------|
| FR-1 | Define arbitrary **energy types** (key, display name, base unit, icon, colour). Ships with sensible defaults but nothing is hardcoded. |
| FR-2 | Define **meters** (≤ ~1000), each bound to an energy type and a **measurement mode** (§5.2). Multiple meters per type. Serial/model/install/retire metadata. |
| FR-3 | Attach one or more **data sources** to a meter: MQTT topic, Tasmota field, HA entity, manual, import, or virtual (formula). Per-source scale/offset, priority, enable flag, last-seen status. |
| FR-4 | **Ingest on schedule / on message** from MQTT (incl. Tasmota) and HA (WebSocket push or REST poll). Idempotent; guard cumulative registers against spurious decreases; sample/debounce high-frequency sources. |
| FR-5 | **Manual entry**: add a reading, delivery, tank level, swap, or correction from the UI. |
| FR-6 | **CSV import** with column mapping, saved mapping profiles, German dialect handling (Appendix A), dry-run preview, and revertible import batches. The four reference CSVs must import correctly. |
| FR-7 | **Consumption normalization**: convert raw readings (register/level/runtime/rate) into normalized consumption/generation in the base unit, handling deltas, swaps, resets, deliveries, runtime×rate. |
| FR-8 | **Virtual meters**: values computed from other meters via a user-defined expression (self-consumption, savings, net). |
| FR-9 | **Tariffs** with time ranges (price history): unit price, base/standing price, feed-in tariff, bonus, discount, tax. Scope: global, per energy type, or per meter. Support **monthly** pricing *and* **day-accurate proration** when price changes mid-period. |
| FR-10 | **Cost categories** (Heizung/Strom/Wasser/Pool …) mapping one or more meters/types → a category; plus **manual flat costs** (e.g. pool) with no meter. |
| FR-11 | **Aggregation**: hourly/daily/monthly/yearly consumption, generation and cost, per meter and per category. |
| FR-12 | **Dashboard** (§8): today/month/year cost KPIs with Δ vs previous period; cost breakdown & "what costs most"; period-over-period difference view; trends with granularity toggle and previous-year overlay; PV panel; oil/consumable panel; meter detail. |
| FR-13 | **PV analytics**: generation, self-consumption, autarky %, self-consumption %, savings. |
| FR-14 | **Consumable/tank analytics**: balance, deliveries log, burner runtime, effective L/h (fixed or empirical), forecast to empty. |
| FR-15 | **REST API + OpenAPI** for ingest (push), query, and automation; API-key auth. Lets HA *push* as an alternative to us *pulling*. |
| FR-16 | **Retention & storage** configurable to satisfy up to 50 years of data (§5.5). |
| FR-17 | **i18n**: English + German UI; locale-aware number/currency/date formatting. |
| FR-18 | **Deploy** via Docker Compose (app + TimescaleDB); Unraid template; healthcheck endpoint; backup guidance. |
| FR-19 | **Auth**: optional local accounts *and* reverse-proxy trust (honour `X-Forwarded-User` behind Authelia/Traefik). |
---
## 4. Architecture & tech stack
### 4.1 Stack (recommended, committed)
| Layer | Choice | Rationale |
|-------|--------|-----------|
| Runtime | **.NET (current LTS — .NET 10; .NET 8 acceptable)**, C# | Matches your toolchain (Rider, C#-first) and the MQTTower precedent (.NET Blazor + MQTT). |
| Web/UI | **ASP.NET Core + Blazor Server** | Same model as MQTTower; server-side keeps DB/time-series logic close, good for a homelab dashboard. |
| Component kit | **MudBlazor** | Mature, clean, good tables/cards/dialogs. |
| Charts | **ApexCharts (Blazor-ApexCharts)** | Solid for time-series, stacked bars, mixed cost/consumption. |
| MQTT | **MQTTnet** | The standard .NET MQTT lib; used for Tasmota + HA-published topics. |
| DB | **PostgreSQL + TimescaleDB** | Postgres you already know, with hypertables, native compression, and continuous aggregates — the right tool for 1000 meters × 50 years. |
| ORM / data | **EF Core (Npgsql)** for schema/migrations/CRUD; **Dapper** for hot-path time-series reads | EF for productivity; Dapper + raw SQL where Timescale features (hypertables, CAggs, `time_bucket`) need it. |
| Background work | **`BackgroundService` / hosted services** | MQTT subscriber, HA poller, aggregation refresh, forecast recompute. |
| Tests | **xUnit + Testcontainers (Timescale image)** | Real DB in integration tests; the 4 CSVs as fixtures. |
| Container | **Docker + docker-compose**; **GHCR** multi-arch (amd64 primary) via GitHub Actions | Homelab-native distribution. |
> The **domain model and DB schema are UI-agnostic.** If a future maintainer swaps Blazor for an SPA, everything from §5–§7 and the REST API in §9 is reusable.
### 4.2 Components & data flow
```
┌──────────── sources ────────────┐
│ Tasmota ──tele/<t>/SENSOR──┐ │
│ HA (WS/REST) ──────────────┤ │ ┌─────────────────────────────┐
│ raw MQTT ──────────────────┼──▶│ Ingestion │ normalize │ TimescaleDB │
│ Manual / CSV import ────────┘ │ workers │ pipeline │ (hypertables│
└──────────────────────────────────┘ │ (deltas, │ + CAggs) │
│ swaps, └──────┬───────┘
│ runtime×r) │ │
└─────────────┘ │
Cost engine (tariff join) │
│ │
┌────────────────┴────────────────┴───┐
│ Blazor dashboard + REST API │
└──────────────────────────────────────┘
```
Ingestion writes **raw `reading`** rows. A normalization step derives **`consumption`** (append-only, base unit). Continuous aggregates roll consumption up to hourly/daily/monthly/yearly. The cost engine joins aggregates with time-ranged tariffs. The dashboard and API read aggregates + cost views (never scan raw for charts).
---
## 5. Data model & database
Design principles: raw readings are immutable audit truth; everything derived (consumption, cost, balances, forecasts) is computed on top and reproducible; the big table is a Timescale hypertable; long-horizon retention is served by aggregates, not by keeping every raw row forever.
### 5.1 Entity overview
- `energy_type` — user-defined categories of measurement.
- `meter` — the "device" (≤1000). Has a **measurement mode**.
- `meter_source` — 0..n ingest bindings per meter.
- `reading` *(hypertable)* — raw timestamped values.
- `consumption` *(hypertable)* — normalized, append-only deltas in base unit.
- `meter_event` — discrete events: swap, reset, delivery, tank level, correction, note.
- `tank` — consumable store: capacity, cm→litre calibration, thresholds, cached balance.
- `tariff` — price components with validity ranges (history).
- `cost_category` + `cost_category_member` — reporting groups (Heizung/Strom/Wasser/Pool).
- `manual_cost` — flat costs with no meter (e.g. pool).
- `ingestion_endpoint` — broker / HA connection configs (secrets by reference).
- `import_batch` — provenance + revert for CSV/manual bulk loads.
- `app_setting` — currency, locale, timezone, retention, fallbacks.
### 5.2 Measurement modes (`meter.mode`)
| Mode | Meaning | Consumption derived by |
|------|---------|------------------------|
| `cumulative_counter` | Monotonic register (Zähler Haus/Netz/Auto, water) | Δ register between readings; handle swaps/resets. |
| `generation_counter` | Monotonic generation register (Solar 1/2) | Δ register → generation. |
| `runtime_counter` | Cumulative operating hours (burner) | Δ hours × rate (fixed or empirical) → consumption. |
| `consumable_balance` | Tank/bottle with deliveries + level | Deliveries add; usage from level-Δ and/or runtime×rate; forecast to empty. |
| `direct_delta` | Source already reports increments | Value *is* the increment. |
| `instant_rate` | Power/flow sensor (optional v1) | Integrate rate over time. |
| `virtual` | Computed from other meters | Evaluate `expression` over referenced meters. |
### 5.3 Schema sketch (PostgreSQL + TimescaleDB)
> Illustrative DDL; EF Core migrations own the relational tables, and **raw-SQL migrations** own the Timescale-specific DDL (`create_hypertable`, compression, continuous aggregates, retention). Timescale objects are *not* expressible through EF's model builder.
```sql
CREATE TABLE energy_type (
id SMALLSERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE, -- 'electricity','water','heating_oil','gas','heat','pool'
display_name TEXT NOT NULL,
base_unit TEXT NOT NULL, -- 'kWh','m3','L','h'
default_mode TEXT NOT NULL, -- see 5.2
icon TEXT,
color_hex TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE meter (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
energy_type_id SMALLINT NOT NULL REFERENCES energy_type(id),
mode TEXT NOT NULL,
unit TEXT NOT NULL, -- defaults from energy_type.base_unit
location TEXT,
serial_number TEXT,
model TEXT,
manufacturer TEXT,
installed_at DATE,
retired_at DATE,
is_active BOOLEAN NOT NULL DEFAULT true,
meta JSONB NOT NULL DEFAULT '{}', -- rate config, formula, tank ref, etc.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_meter_type_active ON meter(energy_type_id, is_active);
CREATE TABLE meter_source (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meter(id) ON DELETE CASCADE,
source_type TEXT NOT NULL, -- 'mqtt','tasmota','homeassistant','manual','import','virtual'
endpoint_id INT REFERENCES ingestion_endpoint(id),
config JSONB NOT NULL DEFAULT '{}', -- topic / field path / entity_id / expression / poll interval
value_kind TEXT NOT NULL, -- 'register','delta','rate','level','runtime'
scale DOUBLE PRECISION NOT NULL DEFAULT 1,
"offset" DOUBLE PRECISION NOT NULL DEFAULT 0,
priority INT NOT NULL DEFAULT 0,
is_enabled BOOLEAN NOT NULL DEFAULT true,
last_seen_at TIMESTAMPTZ,
last_value DOUBLE PRECISION,
last_status TEXT
);
-- BIG TABLE: raw readings
CREATE TABLE reading (
time TIMESTAMPTZ NOT NULL,
meter_id INT NOT NULL REFERENCES meter(id),
value DOUBLE PRECISION NOT NULL, -- register value / level / hours / rate, in meter.unit
source_id INT REFERENCES meter_source(id),
quality SMALLINT NOT NULL DEFAULT 0, -- 0 measured,1 estimated,2 manual,3 imported,4 interpolated
flags INT NOT NULL DEFAULT 0, -- bitmask: reset, anomaly, ...
PRIMARY KEY (meter_id, time)
);
SELECT create_hypertable('reading', 'time',
chunk_time_interval => INTERVAL '30 days');
ALTER TABLE reading SET (timescaledb.compress,
timescaledb.compress_segmentby = 'meter_id',
timescaledb.compress_orderby = 'time DESC');
SELECT add_compression_policy('reading', INTERVAL '30 days');
-- Normalized consumption (append-only deltas in base unit)
CREATE TABLE consumption (
time TIMESTAMPTZ NOT NULL, -- end of the interval this delta covers
meter_id INT NOT NULL REFERENCES meter(id),
amount DOUBLE PRECISION NOT NULL, -- consumption(+) or generation(+) in base unit
kind SMALLINT NOT NULL DEFAULT 0, -- 0 consumption, 1 generation
quality SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (meter_id, time, kind)
);
SELECT create_hypertable('consumption', 'time',
chunk_time_interval => INTERVAL '90 days');
CREATE TABLE meter_event (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meter(id) ON DELETE CASCADE,
time TIMESTAMPTZ NOT NULL,
event_type TEXT NOT NULL, -- 'meter_swap','counter_reset','delivery','tank_level','correction','note'
amount DOUBLE PRECISION, -- delivery litres / correction value
prev_value DOUBLE PRECISION, -- swap: old register final
new_value DOUBLE PRECISION, -- swap: new register initial
unit TEXT,
notes TEXT,
meta JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX ix_event_meter_time ON meter_event(meter_id, time);
CREATE TABLE tank (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meter(id) ON DELETE CASCADE,
capacity DOUBLE PRECISION NOT NULL, -- e.g. 7000
unit TEXT NOT NULL DEFAULT 'L',
calibration JSONB, -- cm→litre curve or geometry for level readings
rate_mode TEXT NOT NULL DEFAULT 'empirical', -- 'fixed' | 'empirical'
fixed_rate DOUBLE PRECISION, -- L per runtime-hour when rate_mode='fixed'
low_threshold DOUBLE PRECISION,
reorder_threshold DOUBLE PRECISION,
cached_balance DOUBLE PRECISION, -- last computed Tank Aktuell
cached_at TIMESTAMPTZ
);
CREATE TABLE tariff (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope_type TEXT NOT NULL, -- 'global','energy_type','meter'
scope_id INT, -- energy_type.id or meter.id (null for global)
component TEXT NOT NULL, -- 'unit_price','base_price','feed_in','bonus','discount','tax'
value DOUBLE PRECISION NOT NULL,
unit TEXT NOT NULL, -- 'EUR/kWh','EUR/m3','EUR/100L','EUR/month'
currency TEXT NOT NULL DEFAULT 'EUR',
valid_from DATE NOT NULL,
valid_to DATE, -- null = open-ended
notes TEXT
);
CREATE INDEX ix_tariff_scope ON tariff(scope_type, scope_id, component, valid_from);
CREATE TABLE cost_category (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
color_hex TEXT,
sort INT NOT NULL DEFAULT 0
);
CREATE TABLE cost_category_member (
category_id INT NOT NULL REFERENCES cost_category(id) ON DELETE CASCADE,
meter_id INT REFERENCES meter(id) ON DELETE CASCADE,
energy_type_id SMALLINT REFERENCES energy_type(id) ON DELETE CASCADE,
CHECK (meter_id IS NOT NULL OR energy_type_id IS NOT NULL)
);
CREATE TABLE manual_cost (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
category_id INT REFERENCES cost_category(id),
meter_id INT REFERENCES meter(id),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
amount DOUBLE PRECISION NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
notes TEXT
);
CREATE TABLE ingestion_endpoint (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
type TEXT NOT NULL, -- 'mqtt_broker','homeassistant'
name TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}', -- host/port/tls/base_url; secrets by *reference* only
is_enabled BOOLEAN NOT NULL DEFAULT true,
last_status TEXT,
last_seen_at TIMESTAMPTZ
);
CREATE TABLE import_batch (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
source_name TEXT,
mapping JSONB,
row_count INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reverted_at TIMESTAMPTZ
);
CREATE TABLE app_setting (
key TEXT PRIMARY KEY,
value JSONB NOT NULL
);
```
### 5.4 Continuous aggregates & cost view
```sql
-- Daily normalized consumption per meter (local-tz buckets)
CREATE MATERIALIZED VIEW consumption_daily
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time, 'Europe/Berlin') AS day,
meter_id, kind,
sum(amount) AS amount
FROM consumption
GROUP BY day, meter_id, kind;
-- + monthly and yearly CAggs the same way (bucket '1 month' / '1 year').
SELECT add_continuous_aggregate_policy('consumption_daily',
start_offset => INTERVAL '3 days', end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour');
```
Cost is **not** baked into a continuous aggregate (tariffs are a slowly-changing dimension; a CAgg can't join them cleanly). Instead, compute cost in a regular SQL view / function on top of the consumption CAggs, resolving the active `unit_price`/`base_price`/`feed_in` for each bucket by date. Support two modes: **monthly price** (one price per calendar month, matching the spreadsheet) and **day-accurate proration** (split a bucket if a price change falls inside it).
### 5.5 Capacity & retention (the 1000×50y requirement)
Worst-case raw volume, 1000 meters:
| Ingest granularity | Rows/day | Rows/year | Rows/50y |
|--------------------|----------|-----------|----------|
| 1/min | 1.44 M | 525 M | **26.3 B** |
| 5/min (typical Tasmota TelePeriod 300) | 288 k | 105 M | **5.25 B** |
| daily (manual/legacy) | 1 k | 365 k | 18 M |
Normalized/rolled-up volumes are tiny regardless: daily consumption = 1000 × 365 × 50 = **18.25 M**, monthly **600 k**, yearly **50 k**.
**Strategy:**
- **Raw `reading`:** Timescale hypertable + columnar compression (`segmentby meter_id`), typically 1020× on monotonic sensor data. Raw is kept for a **configurable window** (default: 3 years) — long enough for full-resolution drill-down.
- **`consumption` + continuous aggregates:** the long-term source of truth. Kept effectively **forever** (they're small), which is what actually satisfies "50 years of data" for dashboards and cost.
- If the user *insists* on 50 years of raw high-frequency data, it's still feasible on a homelab NAS (single-digit TB compressed) — expose raw retention as a setting, don't hardcode.
- Recommend `space` partitioning by `meter_id` only if meter count and query patterns justify it; start with time-only chunks.
> Design conclusion: 1000 meters × 50 years is comfortably within TimescaleDB on modest hardware **provided** dashboards read aggregates and raw retention is bounded. Don't let the UI scan `reading` for charts.
---
## 6. Ingestion
### 6.1 MQTT / Tasmota worker
- A hosted `BackgroundService` maintains a persistent MQTTnet connection per enabled `ingestion_endpoint` of type `mqtt_broker`.
- Subscribes to the union of topics from enabled `meter_source`s (and Tasmota patterns like `tele/+/SENSOR`).
- On message: resolve topic → source(s); extract value via JSON path/template from `config` (Tasmota energy under `ENERGY.Total`, `ENERGY.Today`, `ENERGY.Power`; generic sensors by path); apply `scale`/`offset`; write `reading` with a timestamp (prefer the payload's own time field, else Tasmota `Time`, else receive time).
- **Sampling/debounce:** per-source policy — store on-change and/or at most 1/min for chatty sources, to keep raw volume in check.
- **Idempotent** upsert on `(meter_id, time)`. For `cumulative_counter`/`generation_counter`, reject decreases unless an active `counter_reset`/`meter_swap` event explains it.
### 6.2 Home Assistant connector
- Prefer **WebSocket API** (`auth` with long-lived token → `subscribe_events` / `state_changed`) for push; fall back to **REST poll** `/api/states/<entity_id>` on a per-source interval.
- Extract `state` or a named `attribute`; parse units; write `reading`.
- Optional **backfill** on first connect / after downtime via `/api/history/period`.
- HA can also simply publish to MQTT — in which case it's an `mqtt`/`tasmota` source and no HA connector is needed.
### 6.3 Manual & CSV import
- UI quick-add for readings, deliveries, tank levels, swaps, corrections.
- CSV wizard: upload → detect dialect (Appendix A) → map columns to meters + row semantics → **dry-run preview** (computed consumption/cost shown) → commit as an `import_batch` (revertible).
- Ship the four reference CSVs as built-in example imports and as test fixtures.
### 6.4 Secrets
Tokens/passwords are **never** stored in plaintext in the DB. `ingestion_endpoint.config` holds a *reference* (env var name / Docker secret path); the app resolves at runtime. Document this clearly.
---
## 7. Consumption normalization & cost engine
### 7.1 Register → consumption
For `cumulative_counter`/`generation_counter`: for each new reading, `amount = value previous_value`. Persist to `consumption`. Cross a `meter_swap` as `(old_final prev) + (curr new_initial)`; a `counter_reset` starts a fresh baseline. Ignore/annotate negative deltas that lack an explaining event (flag as anomaly).
### 7.2 Runtime → consumption (burner)
For `runtime_counter`: `amount = Δhours × rate`. `rate` comes from the linked `tank`: `fixed` (nozzle spec, L/h) or `empirical` (`Δlevel ÷ Δhours` measured between deliveries/level reads — reproduce the spreadsheet's 1.87/1.94/2.92 … behaviour). Expose both; default empirical when level data exists, else fixed.
### 7.3 Consumable/tank balance & forecast
`balance(t) = Σ deliveries(≤t) Σ consumption(≤t)`, reconciled to physical `tank_level` events when present (cm → litres via calibration). Forecast to empty from a trailing consumption rate (e.g. last-30-day L/day) → `Vorraussichtliches Ende`. Surface low/reorder thresholds.
### 7.4 Virtual meters
For `virtual`: evaluate `config.expression` (whitelisted, sandboxed — a small safe expression evaluator over referenced meters' consumption/generation series), e.g. `self_consumption = generation grid_feed_in`, `savings = self_consumption * unit_price`. Persist results to `consumption` (or compute on read — decide per §14). This is how PV self-consumption/savings and net figures are modelled without hardcoding.
### 7.5 Cost
`cost(bucket) = Σ(consumption_amount × active_unit_price) + base_price(prorated) feed_in_credit bonus`. Prices resolved by date from `tariff` (time-ranged). Currency from `app_setting`. Provide monthly-price and day-accurate-proration modes (§5.4). Categories roll costs up per `cost_category`; add `manual_cost` for meter-less categories (pool).
---
## 8. Dashboard & UX
### 8.1 Overview
- KPI cards: **Today**, **This month**, **This year** cost — each with Δ (absolute + %) vs the previous comparable period and an ↑/↓ indicator.
- "Cost now" total across all categories.
### 8.2 Cost breakdown / "what costs most"
- Stacked bar or donut by `cost_category` for a selectable period; ranked list (most → least).
- **Difference view** (explicitly requested): a table answering *"what cost more, what cost less this time"* — per category **and** per meter, **this month vs last month** and **this year vs last year**, columns `now | previous | Δ | Δ% | ↑/↓`, sorted by absolute impact.
### 8.3 Trends
- Consumption and cost over time; **granularity toggle** day/week/month/year; per-meter or per-category; **previous-year overlay**.
### 8.4 PV / Solar panel
- Generation, self-consumption, grid feed/draw, **savings (Ersparnis)**, **autarky %**, **self-consumption %**. Time-filtered.
### 8.5 Oil / consumable panel
- Tank level (cm + L), balance vs capacity gauge, deliveries log, burner runtime, effective **L/h** (fixed/empirical), **forecast to empty**, monthly cost.
### 8.6 Meter detail
- Raw readings, normalized consumption, source status (last-seen, last value), tariff timeline, events (swaps/deliveries/corrections), measured-vs-estimated markers.
### 8.7 Admin / config
- CRUD for energy types, meters, sources, tariffs, cost categories, connectors; retention & locale/currency settings; import wizard; API keys.
> **Legacy monthly history:** imported data is monthly-granular. Offer per-import choice: keep native monthly buckets, or **linearly interpolate to daily** (energietracker-style) so old and new data render on the same axes. Interpolated points are marked `quality = interpolated`.
---
## 9. REST API (v1)
OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the session.
| Method & path | Purpose |
|---------------|---------|
| `POST /api/v1/readings` | Ingest one/many readings (idempotent). Lets HA **push** instead of us pulling. |
| `GET /api/v1/meters` · `POST/PUT/DELETE` | Meter CRUD. |
| `GET /api/v1/energy-types` · CRUD | Energy-type CRUD. |
| `GET /api/v1/consumption?meter=&from=&to=&bucket=` | Normalized consumption/generation. |
| `GET /api/v1/cost?scope=&id=&from=&to=&bucket=` | Cost by meter/category/type. |
| `GET /api/v1/dashboard/summary` | KPI cards + Δ payload. |
| `POST /api/v1/events` | Delivery, swap, tank level, correction. |
| `GET/POST /api/v1/tariffs` | Tariff CRUD (time-ranged). |
| `POST /api/v1/import` (multipart) | CSV import with a mapping profile. |
| `GET /api/v1/sources/status` | Connector/source health. |
| `GET /healthz` | Liveness/readiness (for Gatus). |
---
## 10. Non-functional
- **Time & DST:** store UTC; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
- **Auth:** optional built-in local accounts; **reverse-proxy trust** mode honouring `X-Forwarded-User`/`Remote-User` behind Authelia/Traefik; API keys for machine access. Default: single admin user + one ingest API key.
- **Observability:** `/healthz`, structured logs (Serilog), optional Prometheus `/metrics`.
- **Config:** environment variables + a settings UI; secrets via env/Docker secrets (never in DB plaintext).
- **i18n:** `en` (default for OSS) + `de`; locale-aware number/currency/date. Ship a German locale that matches the source data conventions.
- **Backup:** document `pg_dump`/Timescale backup; provide a full **JSON export/import** for portability.
- **Performance:** dashboards read aggregates only; raw reads paginated and time-bounded.
---
## 11. Repository, CI, licensing
```
/ CLAUDE.md, README.md, LICENSE, docker-compose.yml
/src
/Core domain entities, enums, interfaces, expression eval
/Infrastructure EF Core + Npgsql, Dapper repos, Timescale SQL migrations,
MQTT client, HA client, CSV importer
/App ASP.NET Core host: Blazor Server UI + REST API + hosted workers
/tests
/Core.Tests unit: deltas, swaps, tariff resolution, oil rate, CSV parsing
/Integration.Tests Testcontainers (Timescale): ingest→aggregate→cost e2e
/fixtures the 4 reference CSVs + expected outputs
/deploy
Dockerfile docker-compose.yml (app + timescaledb), unraid-template.xml
/docs architecture, setup, HA/Tasmota wiring, API, screenshots
```
- **CI (GitHub Actions):** build → test (spin Timescale) → publish Docker image to **GHCR** (amd64; add arm64 if desired) on tag.
- **License:** pick before release — **MIT** (max adoption; matches energietracker/your prior assets) or **AGPL-3.0** (keeps hosted forks open). Default suggestion: **MIT**, unless keeping SaaS forks open-source matters to you.
- **Docs:** a "wire up HA/Tasmota" guide is the highest-leverage doc for adoption.
---
## 12. Milestone roadmap (build order)
- **M0 — Scaffold.** Solution + 3 projects + tests; `docker-compose` with TimescaleDB; EF Core + first migration; `/healthz`. *Exit:* app boots against Timescale in Docker.
- **M1 — Domain & schema.** `energy_type`, `meter`, `meter_source`, `reading` hypertable, `consumption` hypertable + normalization pipeline (register/runtime/swap/reset), `tariff`, seed defaults. *Exit:* insert readings → correct `consumption`, unit-tested incl. swaps.
- **M2 — Manual entry & CSV import.** German-dialect importer (Appendix A), mapping profiles, dry-run + revertible batches; the 4 CSVs import and reconcile. *Exit:* importing the reference CSVs reproduces the spreadsheet's consumption/cost within tolerance (§13).
- **M3 — Live ingestion.** MQTTnet worker + Tasmota field mapping; HA connector (WebSocket + REST poll); idempotency; source status. *Exit:* a Tasmota plug and an HA entity land as readings automatically.
- **M4 — Aggregation & cost engine.** Continuous aggregates (hourly/daily/monthly/yearly); tariff-aware cost view (monthly + prorated); cost categories + manual costs. *Exit:* `GET /cost` and category rollups correct vs fixtures.
- **M5 — Dashboard.** Overview KPIs + Δ; cost breakdown + **difference view**; trends w/ granularity + prev-year overlay; PV panel; oil/consumable panel; meter detail. *Exit:* all §8 views render on imported + live data.
- **M6 — API & auth.** REST + OpenAPI; API keys; reverse-proxy trust. *Exit:* HA can push via `POST /readings`; Swagger published.
- **M7 — Release polish.** i18n (de/en); retention settings; JSON export/import; Unraid template; CI → GHCR; README + wiring guide. *Exit:* `docker compose up` from a clean host yields a working, documented instance.
---
## 13. Testing strategy
- **Unit:** consumption deltas incl. **water swap** (…861→2) and counter resets; **oil** empirical vs fixed L/h and forecast; tariff time-range resolution + mid-month proration; virtual-meter expression eval; **CSV parsing** of the exact reference dialect (decimal comma, unit suffixes, currency, `DD.MM.YYYY` vs `Monat YYYY`, summary-row skipping, zero-placeholder rows).
- **Integration (Testcontainers + Timescale):** end-to-end ingest → normalize → aggregate → cost; continuous-aggregate refresh; hypertable compression sanity.
- **Golden fixtures:** the four CSVs with expected monthly consumption/cost tables. A regression test asserts computed ≈ spreadsheet (define tolerance for rounding; the sheet rounds to cents / whole kWh).
- **Load smoke (optional):** synthetic 1000-meter × N-year generator to validate aggregate query latency and compression ratio.
---
## 14. Open questions & defaults
Pick the **default** and flag it if unsure; only ask when a question isn't listed here.
1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.)
2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`.
3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration.
4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed).
5. **Multi-user?** *Default:* single admin + reverse-proxy trust; full accounts post-v1.
6. **.NET version pin.** *Default:* current LTS at implementation time; keep `TargetFramework` in one place.
7. **License.** *Default:* MIT unless you want AGPL's copyleft on hosted forks.
8. **Name.** `MeterVault` is a placeholder — decide before first public tag.
---
## Appendix A — CSV import dialect (from the reference files)
- **Field separator:** comma; fields quoted when they contain a comma.
- **Decimal separator:** comma (`180,8244706`). **Thousands separator:** dot (`2.940,19`).
- **Currency:** trailing `€` with a space (`120,00 €`); parse to `(amount, currency)`.
- **Unit suffixes on values:** strip and validate (`411kWh` → 411 kWh; `49` cm; `2287` L).
- **Dates:** `Monat YYYY` German month names for monthly tables; `DD.MM.YYYY` for event rows. Support both; store as timestamptz (month tables → first-of-month or period bucket).
- **Skip rows:** summary/label rows — `Total`, `Heute`, `Seitbeginn Tage`, `Seit YYYY`, and any embedded side-tables.
- **No-data rows:** all-zero future placeholders (e.g. Dec 2026) → ignore.
- **Negatives are valid** (savings, grid balance).
- **Column mapping is explicit** (a wizard), because these sheets pack multiple meters and derived columns side by side; ship saved mapping profiles for each of the four sheet shapes.
## Appendix B — Glossary (source ↔ model)
| Sheet term | Model concept |
|------------|---------------|
| Zähler (Haus/Netz/Auto/Solar) | `meter` (`cumulative_`/`generation_counter`) |
| Verbrauch / Erzeugung | `consumption.amount` (kind 0/1) |
| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions |
| €/kWh, €/m³, €/100l | `tariff.unit_price` (time-ranged) |
| Grundpreis / Abschlag | `tariff.base_price` |
| Betriebststunden | `runtime_counter` meter |
| Verbrauch / Betrieb Stunde | derived L/h (`tank.rate_mode`) |
| Lieferungmenge | `meter_event` `delivery` |
| Füllstand cm / Tank Aktuell | `meter_event` `tank_level` / `tank.cached_balance` |
| Vorraussichtliches Ende | forecast-to-empty |
| Heizung / Strom / Wasser / Pool Betrieb | `cost_category` (Pool via `manual_cost`) |
| Zähler swap (…861→2) | `meter_event` `meter_swap` |
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
@@ -0,0 +1,70 @@
Heute,,,,13.07.2026,,,Gesammtlieferung,Tankgröße,,Verbrauch / Monat,Verbrauch / Tag,Verbrauch / Jahr,,,,,
Seitbeginn Tage,,,,10625,,,64042,7000,,"180,8244706","6,03","2200,03",,,,,
Seit 2023,,,,1289,-7707,,,,,"179,3638479","5,98","2182,26",,,,,
Datum,Betriebststunden,Differenz Betrieb,Betrieb / Tag,Füllstand cm,Tankfüllstand,Vorhersage,Lieferungmenge,Tank Aktuell,Differenz Tank,Differenz Tage,Verbrauch / Tag,Vorraussichtliches Ende,Verbrauch / Betrieb Stunde,€/100l,Monatskosten,,
10.06.1997,,,,,,,5232,5232,0,,,,"2,08","120,00 €",,,
09.11.1998,,,,,,,2869,2869,-5232,,,,,"120,00 €",,,
24.07.2001,,,,,,,6053,6053,-2869,,,,,"120,00 €",,,
19.10.2004,,,,,,,3000,3000,-6053,,,,,"120,00 €",,,
07.12.2005,,,,,,,3000,3000,-3000,,,,,"120,00 €",,,
14.11.2006,,,,,,,4999,4999,-3000,,,,,"120,00 €",,,
10.09.2009,,,,,,,4911,4911,-4999,,,,,"120,00 €",,,
18.10.2010,0,,,,,,2347,2347,-4911,,,,,"120,00 €",,,
30.08.2013,,,,,,,4000,4000,-2347,,,,,"120,00 €",,,
29.06.2015,,,,,,,4329,4329,-4000,,,,,"120,00 €",,,
14.12.2017,,,,,,,3002,3002,-4329,,,,,"120,00 €",,,
18.06.2019,,,,,0,,6000,6000,-3002,,,,,"120,00 €",,,
07.09.2020,,,,,,,3500,3500,-6000,,,,,"120,00 €",,,
08.09.2022,,,,35,1633,1633,0,1633,-1867,731,"2,55","270,98",,"120,00 €",,,
14.10.2022,7758,,,34,1587,1587,0,1587,-47,36,"1,30","263,24",,"120,00 €",,,
28.11.2022,7758,0,"0,00",27,1260,1260,0,1260,-327,45,"7,26","209,04",,"120,00 €",,,
05.12.2022,7785,27,"3,86",85,3967,3967,3000,3967,-293,7,"41,90","658,10","10,86","120,00 €",,,
Dezember 2022,7952,167,"6,42",83,3873,3873,0,3873,-93,26,"3,59","642,61","0,56","120,00 €","112,00 €",,
Januar 2023,8127,175,"5,65",77,3593,3593,0,3593,-280,31,"9,03","601,01","1,60","120,00 €","336,00 €",,
Februar 2023,8281,154,"4,97",71,3313,3313,0,3313,-280,31,"9,03","554,18","1,82","120,00 €","336,00 €",,
März 2023,8442,161,"5,75",66,3080,3080,0,3080,-233,28,"8,33","515,15","1,45","120,00 €","280,00 €",,
April 2023,8563,121,"3,90",61,2847,2847,0,2847,-233,31,"7,53","476,13","1,93","120,00 €","280,00 €",,
Mai 2023,8619,56,"1,87",57,2660,2660,0,2660,-187,30,"6,22","444,91","3,33","120,00 €","224,00 €",,
Juni 2023,8669,50,"1,61",54,2520,2520,0,2520,-140,31,"4,52","421,49","2,80","120,00 €","168,00 €",,
Juli 2023,8716,47,"1,57",51,2380,2380,0,2380,-140,30,"4,67","398,07","2,98","120,00 €","168,00 €",,
August 2023,8766,50,"1,61",49,2287,2287,0,2287,-93,31,"3,01","382,46","1,87","120,00 €","112,00 €",,
September 2023,8814,48,"1,55",47,2193,2193,0,2193,-93,31,"3,01","366,85","1,94","120,00 €","112,00 €",,
Oktober 2023,8878,64,"2,13",43,2007,2007,0,2007,-187,30,"6,22","335,63","2,92","120,00 €","224,00 €",2023,
November 2023,8989,111,"3,58",39,1820,1820,0,1820,-187,31,"6,02","304,41","1,68","120,00 €","224,00 €",Kosten,Verbrauch
Dezember 2023,9150,161,"5,37",34,1587,1587,0,1587,-233,30,"7,78","265,38","1,45","120,00 €","280,00 €","2.744,00 €",2287
Januar 2024,9340,190,"6,13",27,1260,1260,0,1260,-327,31,"10,54","210,74","1,72","120,00 €","392,00 €",,
Februar 2024,9460,120,"3,87",23,1073,1073,0,1073,-187,31,"6,02","179,52","1,56","120,00 €","224,00 €",,
März 2024,9596,136,"4,69",18,840,840,0,840,-233,29,"8,05","140,50","1,72","120,00 €","280,00 €",,
April 2024,9668,72,"2,32",10,467,467,0,467,-373,31,"12,04","78,05","5,19","120,00 €","448,00 €",,
Mai 2024,9728,60,"2,00",87,4060,4060,3800,4060,-207,30,"6,89","679,07","3,44","100,00 €","206,67 €",,
Juni 2024,9776,48,"1,55",85,3967,3967,0,3967,-93,31,"3,01","663,46","1,94","100,00 €","93,33 €",,
Juli 2024,9832,56,"1,87",84,3920,3920,0,3920,-47,30,"1,56","655,65","0,83","100,00 €","46,67 €",,
August 2024,9872,40,"1,29",83,3873,3873,0,3873,-47,31,"1,51","647,85","1,17","100,00 €","46,67 €",,
September 2024,9932,60,"1,94",82,3827,3827,0,3827,-47,31,"1,51","640,04","0,78","100,00 €","46,67 €",,
Oktober 2024,10005,73,"2,43",80,3733,3733,0,3733,-93,30,"3,11","624,43","1,28","100,00 €","93,33 €",,
November 2024,10134,129,"4,16",76,3547,3547,0,3547,-187,31,"6,02","593,21","1,45","100,00 €","186,67 €",,
Dezember 2024,10307,173,"5,77",70,3267,3267,0,3267,-280,30,"9,33","546,38","1,62","100,00 €","280,00 €",2.344 €,
Januar 2025,10490,183,"5,90",61,2847,2847,0,2847,-420,31,"13,55","476,13","2,30","100,00 €","420,00 €",,
Februar 2025,10683,193,"6,23",55,2567,2567,0,2567,-280,31,"9,03","429,29","1,45","100,00 €","280,00 €",,
März 2025,10843,160,"5,71",50,2333,2333,0,2333,-233,28,"8,33","390,27","1,46","100,00 €","233,33 €",,
April 2025,10928,85,"2,74",47,2193,2193,0,2193,-140,31,"4,52","366,85","1,65","100,00 €","140,00 €",,
Mai 2025,10991,63,"2,10",43,2007,2007,0,2007,-187,30,"6,22","335,63","2,96","100,00 €","186,67 €",,
Juni 2025,11033,42,"1,35",40,1867,1867,0,1867,-140,31,"4,52","312,21","3,33","100,00 €","140,00 €",,
Juli 2025,11086,53,"1,77",39,1820,1820,0,1820,-47,30,"1,56","304,41","0,88","100,00 €","46,67 €",,
August 2025,11128,42,"1,35",38,1773,1773,0,1773,-47,31,"1,51","296,60","1,11","100,00 €","46,67 €",,
September 2025,11178,50,"1,61",36,1680,1680,0,1680,-93,31,"3,01","280,99","1,87","100,00 €","93,33 €",,
Oktober 2025,11245,67,"2,23",35,1633,1633,0,1633,-47,30,"1,56","273,19","0,70","100,00 €","46,67 €",,
November 2025,11352,107,"3,45",32,1493,1493,0,1493,-140,31,"4,52","249,77","1,31","100,00 €","140,00 €",,
Dezember 2025,11536,184,"6,13",25,1167,1167,0,1167,-327,30,"10,89","195,13","1,78","100,00 €","326,67 €",2.100 €,
Januar 2026,11776,240,"7,74",100,4667,4667,4000,4667,-500,31,"16,13","780,54","2,08","91,00 €","455,00 €",,
Februar 2026,11954,178,"5,74",93,4340,4340,0,4340,-327,31,"10,54","725,90","1,84","91,00 €","297,27 €",,
März 2026,12047,93,"3,32",89,4153,4153,0,4153,-187,28,"6,67","694,68","2,01","91,00 €","169,87 €",,
April 2026,12118,71,"2,29",87,4060,4060,0,4060,-93,31,"3,01","679,07","1,31","91,00 €","84,93 €",,
Mai 2026,12174,56,"1,87",85,3967,3967,0,3967,-93,30,"3,11","663,46","1,67","91,00 €","84,93 €",,
Juni 2026,,,,,0,3787,0,0,0,31,,"0,00",,"91,00 €","0,00 €",,
Juli 2026,,,,,0,3608,0,0,0,30,,"0,00",,"91,00 €","0,00 €",,
August 2026,,,,,0,3429,0,0,0,31,,"0,00",,"91,00 €","0,00 €",,
September 2026,,,,,0,3249,0,0,0,31,,"0,00",,"91,00 €","0,00 €",,
Oktober 2026,,,,,0,3070,0,0,0,30,,"0,00",,"91,00 €","0,00 €",,
November 2026,,,,,0,2890,0,0,0,31,,"0,00",,"91,00 €","0,00 €",,
Dezember 2026,,,,,0,2711,0,0,0,30,,"0,00",,"91,00 €","0,00 €",1.092 €,
1 Heute 13.07.2026 Gesammtlieferung Tankgröße Verbrauch / Monat Verbrauch / Tag Verbrauch / Jahr
2 Seitbeginn Tage 10625 64042 7000 180,8244706 6,03 2200,03
3 Seit 2023 1289 -7707 179,3638479 5,98 2182,26
4 Datum Betriebststunden Differenz Betrieb Betrieb / Tag Füllstand cm Tankfüllstand Vorhersage Lieferungmenge Tank Aktuell Differenz Tank Differenz Tage Verbrauch / Tag Vorraussichtliches Ende Verbrauch / Betrieb Stunde €/100l Monatskosten
5 10.06.1997 5232 5232 0 2,08 120,00 €
6 09.11.1998 2869 2869 -5232 120,00 €
7 24.07.2001 6053 6053 -2869 120,00 €
8 19.10.2004 3000 3000 -6053 120,00 €
9 07.12.2005 3000 3000 -3000 120,00 €
10 14.11.2006 4999 4999 -3000 120,00 €
11 10.09.2009 4911 4911 -4999 120,00 €
12 18.10.2010 0 2347 2347 -4911 120,00 €
13 30.08.2013 4000 4000 -2347 120,00 €
14 29.06.2015 4329 4329 -4000 120,00 €
15 14.12.2017 3002 3002 -4329 120,00 €
16 18.06.2019 0 6000 6000 -3002 120,00 €
17 07.09.2020 3500 3500 -6000 120,00 €
18 08.09.2022 35 1633 1633 0 1633 -1867 731 2,55 270,98 120,00 €
19 14.10.2022 7758 34 1587 1587 0 1587 -47 36 1,30 263,24 120,00 €
20 28.11.2022 7758 0 0,00 27 1260 1260 0 1260 -327 45 7,26 209,04 120,00 €
21 05.12.2022 7785 27 3,86 85 3967 3967 3000 3967 -293 7 41,90 658,10 10,86 120,00 €
22 Dezember 2022 7952 167 6,42 83 3873 3873 0 3873 -93 26 3,59 642,61 0,56 120,00 € 112,00 €
23 Januar 2023 8127 175 5,65 77 3593 3593 0 3593 -280 31 9,03 601,01 1,60 120,00 € 336,00 €
24 Februar 2023 8281 154 4,97 71 3313 3313 0 3313 -280 31 9,03 554,18 1,82 120,00 € 336,00 €
25 März 2023 8442 161 5,75 66 3080 3080 0 3080 -233 28 8,33 515,15 1,45 120,00 € 280,00 €
26 April 2023 8563 121 3,90 61 2847 2847 0 2847 -233 31 7,53 476,13 1,93 120,00 € 280,00 €
27 Mai 2023 8619 56 1,87 57 2660 2660 0 2660 -187 30 6,22 444,91 3,33 120,00 € 224,00 €
28 Juni 2023 8669 50 1,61 54 2520 2520 0 2520 -140 31 4,52 421,49 2,80 120,00 € 168,00 €
29 Juli 2023 8716 47 1,57 51 2380 2380 0 2380 -140 30 4,67 398,07 2,98 120,00 € 168,00 €
30 August 2023 8766 50 1,61 49 2287 2287 0 2287 -93 31 3,01 382,46 1,87 120,00 € 112,00 €
31 September 2023 8814 48 1,55 47 2193 2193 0 2193 -93 31 3,01 366,85 1,94 120,00 € 112,00 €
32 Oktober 2023 8878 64 2,13 43 2007 2007 0 2007 -187 30 6,22 335,63 2,92 120,00 € 224,00 € 2023
33 November 2023 8989 111 3,58 39 1820 1820 0 1820 -187 31 6,02 304,41 1,68 120,00 € 224,00 € Kosten Verbrauch
34 Dezember 2023 9150 161 5,37 34 1587 1587 0 1587 -233 30 7,78 265,38 1,45 120,00 € 280,00 € 2.744,00 € 2287
35 Januar 2024 9340 190 6,13 27 1260 1260 0 1260 -327 31 10,54 210,74 1,72 120,00 € 392,00 €
36 Februar 2024 9460 120 3,87 23 1073 1073 0 1073 -187 31 6,02 179,52 1,56 120,00 € 224,00 €
37 März 2024 9596 136 4,69 18 840 840 0 840 -233 29 8,05 140,50 1,72 120,00 € 280,00 €
38 April 2024 9668 72 2,32 10 467 467 0 467 -373 31 12,04 78,05 5,19 120,00 € 448,00 €
39 Mai 2024 9728 60 2,00 87 4060 4060 3800 4060 -207 30 6,89 679,07 3,44 100,00 € 206,67 €
40 Juni 2024 9776 48 1,55 85 3967 3967 0 3967 -93 31 3,01 663,46 1,94 100,00 € 93,33 €
41 Juli 2024 9832 56 1,87 84 3920 3920 0 3920 -47 30 1,56 655,65 0,83 100,00 € 46,67 €
42 August 2024 9872 40 1,29 83 3873 3873 0 3873 -47 31 1,51 647,85 1,17 100,00 € 46,67 €
43 September 2024 9932 60 1,94 82 3827 3827 0 3827 -47 31 1,51 640,04 0,78 100,00 € 46,67 €
44 Oktober 2024 10005 73 2,43 80 3733 3733 0 3733 -93 30 3,11 624,43 1,28 100,00 € 93,33 €
45 November 2024 10134 129 4,16 76 3547 3547 0 3547 -187 31 6,02 593,21 1,45 100,00 € 186,67 €
46 Dezember 2024 10307 173 5,77 70 3267 3267 0 3267 -280 30 9,33 546,38 1,62 100,00 € 280,00 € 2.344 €
47 Januar 2025 10490 183 5,90 61 2847 2847 0 2847 -420 31 13,55 476,13 2,30 100,00 € 420,00 €
48 Februar 2025 10683 193 6,23 55 2567 2567 0 2567 -280 31 9,03 429,29 1,45 100,00 € 280,00 €
49 März 2025 10843 160 5,71 50 2333 2333 0 2333 -233 28 8,33 390,27 1,46 100,00 € 233,33 €
50 April 2025 10928 85 2,74 47 2193 2193 0 2193 -140 31 4,52 366,85 1,65 100,00 € 140,00 €
51 Mai 2025 10991 63 2,10 43 2007 2007 0 2007 -187 30 6,22 335,63 2,96 100,00 € 186,67 €
52 Juni 2025 11033 42 1,35 40 1867 1867 0 1867 -140 31 4,52 312,21 3,33 100,00 € 140,00 €
53 Juli 2025 11086 53 1,77 39 1820 1820 0 1820 -47 30 1,56 304,41 0,88 100,00 € 46,67 €
54 August 2025 11128 42 1,35 38 1773 1773 0 1773 -47 31 1,51 296,60 1,11 100,00 € 46,67 €
55 September 2025 11178 50 1,61 36 1680 1680 0 1680 -93 31 3,01 280,99 1,87 100,00 € 93,33 €
56 Oktober 2025 11245 67 2,23 35 1633 1633 0 1633 -47 30 1,56 273,19 0,70 100,00 € 46,67 €
57 November 2025 11352 107 3,45 32 1493 1493 0 1493 -140 31 4,52 249,77 1,31 100,00 € 140,00 €
58 Dezember 2025 11536 184 6,13 25 1167 1167 0 1167 -327 30 10,89 195,13 1,78 100,00 € 326,67 € 2.100 €
59 Januar 2026 11776 240 7,74 100 4667 4667 4000 4667 -500 31 16,13 780,54 2,08 91,00 € 455,00 €
60 Februar 2026 11954 178 5,74 93 4340 4340 0 4340 -327 31 10,54 725,90 1,84 91,00 € 297,27 €
61 März 2026 12047 93 3,32 89 4153 4153 0 4153 -187 28 6,67 694,68 2,01 91,00 € 169,87 €
62 April 2026 12118 71 2,29 87 4060 4060 0 4060 -93 31 3,01 679,07 1,31 91,00 € 84,93 €
63 Mai 2026 12174 56 1,87 85 3967 3967 0 3967 -93 30 3,11 663,46 1,67 91,00 € 84,93 €
64 Juni 2026 0 3787 0 0 0 31 0,00 91,00 € 0,00 €
65 Juli 2026 0 3608 0 0 0 30 0,00 91,00 € 0,00 €
66 August 2026 0 3429 0 0 0 31 0,00 91,00 € 0,00 €
67 September 2026 0 3249 0 0 0 31 0,00 91,00 € 0,00 €
68 Oktober 2026 0 3070 0 0 0 30 0,00 91,00 € 0,00 €
69 November 2026 0 2890 0 0 0 31 0,00 91,00 € 0,00 €
70 Dezember 2026 0 2711 0 0 0 30 0,00 91,00 € 0,00 € 1.092 €
+53
View File
@@ -0,0 +1,53 @@
Datum,Jahreskosten,Kosten,Heizung,Strom,Wasser,Pool Betrieb
September 2022,,"0,00 €","0,00 €","0,00 €","0,00 €",
Oktober 2022,,"66,56 €","0,00 €","66,56 €","0,00 €",
November 2022,,"133,12 €","0,00 €","133,12 €","0,00 €",
Dezember 2022,"421,52 €","221,84 €","0,00 €","151,84 €","70,00 €",
Januar 2023,,"920,80 €","336,00 €","514,80 €","70,00 €",
Februar 2023,,"785,12 €","336,00 €","384,12 €","65,00 €",
März 2023,,"620,72 €","280,00 €","280,72 €","60,00 €",
April 2023,,"638,48 €","280,00 €","293,48 €","65,00 €",
Mai 2023,,"813,33 €","224,00 €","414,33 €","175,00 €",X
Juni 2023,,"714,74 €","168,00 €","421,74 €","125,00 €",X
Juli 2023,,"605,99 €","168,00 €","347,99 €","90,00 €",X
August 2023,,"577,04 €","112,00 €","365,04 €","100,00 €",X
September 2023,,"439,37 €","112,00 €","232,37 €","95,00 €",
Oktober 2023,,"560,84 €","224,00 €","276,84 €","60,00 €",
November 2023,,"573,52 €","224,00 €","294,52 €","55,00 €",
Dezember 2023,"7.904,46 €","654,51 €","280,00 €","314,51 €","60,00 €",
Januar 2024,,"818,93 €","392,00 €","356,93 €","70,00 €",
Februar 2024,,"557,72 €","224,00 €","263,72 €","70,00 €",
März 2024,,"610,97 €","280,00 €","245,97 €","85,00 €",
April 2024,,"843,34 €","448,00 €","200,34 €","195,00 €",
Mai 2024,,"446,10 €","206,67 €","164,43 €","75,00 €",
Juni 2024,,"448,40 €","93,33 €","200,07 €","155,00 €",X
Juli 2024,,"448,91 €","46,67 €","327,24 €","75,00 €",X
August 2024,,"476,74 €","46,67 €","335,07 €","95,00 €",X
September 2024,,"416,11 €","46,67 €","289,44 €","80,00 €",X
Oktober 2024,,"439,13 €","93,33 €","280,80 €","65,00 €",
November 2024,,"601,88 €","186,67 €","330,21 €","85,00 €",
Dezember 2024,"6.783,05 €","674,83 €","280,00 €","304,83 €","90,00 €",
Januar 2025,,"904,60 €","420,00 €","399,60 €","85,00 €",
Februar 2025,,"696,92 €","280,00 €","331,92 €","85,00 €",
März 2025,,"589,17 €","233,33 €","285,84 €","70,00 €",
April 2025,,"400,40 €","140,00 €","185,40 €","75,00 €",
Mai 2025,,"658,31 €","186,67 €","386,64 €","85,00 €",
Juni 2025,,"719,60 €","140,00 €","399,60 €","180,00 €",X
Juli 2025,,"614,03 €","46,67 €","477,36 €","90,00 €",X
August 2025,,"723,11 €","46,67 €","586,44 €","90,00 €",X
September 2025,,"530,09 €","93,33 €","356,76 €","80,00 €",
Oktober 2025,,"559,71 €","46,67 €","428,04 €","85,00 €",
November 2025,,"634,84 €","140,00 €","429,84 €","65,00 €",
Dezember 2025,"7.907,64 €","876,87 €","326,67 €","475,20 €","75,00 €",
Januar 2026,,"915,34 €","455,00 €","362,34 €","98,00 €",
Februar 2026,,"673,35 €","297,27 €","271,08 €","105,00 €",
März 2026,,"521,38 €","169,87 €","246,51 €","105,00 €",
April 2026,,"409,90 €","84,93 €","191,97 €","133,00 €",
Mai 2026,,"420,22 €","84,93 €","223,29 €","112,00 €",
Juni 2026,,"0,00 €","0,00 €","0,00 €","0,00 €",
Juli 2026,,"0,00 €","0,00 €","0,00 €","0,00 €",
August 2026,,"0,00 €","0,00 €","0,00 €","0,00 €",
September 2026,,"0,00 €","0,00 €","0,00 €","0,00 €",
Oktober 2026,,"0,00 €","0,00 €","0,00 €","0,00 €",
November 2026,,"0,00 €","0,00 €","0,00 €","0,00 €",
Dezember 2026,"2.940,19 €","0,00 €","0,00 €","0,00 €","0,00 €",
1 Datum Jahreskosten Kosten Heizung Strom Wasser Pool Betrieb
2 September 2022 0,00 € 0,00 € 0,00 € 0,00 €
3 Oktober 2022 66,56 € 0,00 € 66,56 € 0,00 €
4 November 2022 133,12 € 0,00 € 133,12 € 0,00 €
5 Dezember 2022 421,52 € 221,84 € 0,00 € 151,84 € 70,00 €
6 Januar 2023 920,80 € 336,00 € 514,80 € 70,00 €
7 Februar 2023 785,12 € 336,00 € 384,12 € 65,00 €
8 März 2023 620,72 € 280,00 € 280,72 € 60,00 €
9 April 2023 638,48 € 280,00 € 293,48 € 65,00 €
10 Mai 2023 813,33 € 224,00 € 414,33 € 175,00 € X
11 Juni 2023 714,74 € 168,00 € 421,74 € 125,00 € X
12 Juli 2023 605,99 € 168,00 € 347,99 € 90,00 € X
13 August 2023 577,04 € 112,00 € 365,04 € 100,00 € X
14 September 2023 439,37 € 112,00 € 232,37 € 95,00 €
15 Oktober 2023 560,84 € 224,00 € 276,84 € 60,00 €
16 November 2023 573,52 € 224,00 € 294,52 € 55,00 €
17 Dezember 2023 7.904,46 € 654,51 € 280,00 € 314,51 € 60,00 €
18 Januar 2024 818,93 € 392,00 € 356,93 € 70,00 €
19 Februar 2024 557,72 € 224,00 € 263,72 € 70,00 €
20 März 2024 610,97 € 280,00 € 245,97 € 85,00 €
21 April 2024 843,34 € 448,00 € 200,34 € 195,00 €
22 Mai 2024 446,10 € 206,67 € 164,43 € 75,00 €
23 Juni 2024 448,40 € 93,33 € 200,07 € 155,00 € X
24 Juli 2024 448,91 € 46,67 € 327,24 € 75,00 € X
25 August 2024 476,74 € 46,67 € 335,07 € 95,00 € X
26 September 2024 416,11 € 46,67 € 289,44 € 80,00 € X
27 Oktober 2024 439,13 € 93,33 € 280,80 € 65,00 €
28 November 2024 601,88 € 186,67 € 330,21 € 85,00 €
29 Dezember 2024 6.783,05 € 674,83 € 280,00 € 304,83 € 90,00 €
30 Januar 2025 904,60 € 420,00 € 399,60 € 85,00 €
31 Februar 2025 696,92 € 280,00 € 331,92 € 85,00 €
32 März 2025 589,17 € 233,33 € 285,84 € 70,00 €
33 April 2025 400,40 € 140,00 € 185,40 € 75,00 €
34 Mai 2025 658,31 € 186,67 € 386,64 € 85,00 €
35 Juni 2025 719,60 € 140,00 € 399,60 € 180,00 € X
36 Juli 2025 614,03 € 46,67 € 477,36 € 90,00 € X
37 August 2025 723,11 € 46,67 € 586,44 € 90,00 € X
38 September 2025 530,09 € 93,33 € 356,76 € 80,00 €
39 Oktober 2025 559,71 € 46,67 € 428,04 € 85,00 €
40 November 2025 634,84 € 140,00 € 429,84 € 65,00 €
41 Dezember 2025 7.907,64 € 876,87 € 326,67 € 475,20 € 75,00 €
42 Januar 2026 915,34 € 455,00 € 362,34 € 98,00 €
43 Februar 2026 673,35 € 297,27 € 271,08 € 105,00 €
44 März 2026 521,38 € 169,87 € 246,51 € 105,00 €
45 April 2026 409,90 € 84,93 € 191,97 € 133,00 €
46 Mai 2026 420,22 € 84,93 € 223,29 € 112,00 €
47 Juni 2026 0,00 € 0,00 € 0,00 € 0,00 €
48 Juli 2026 0,00 € 0,00 € 0,00 € 0,00 €
49 August 2026 0,00 € 0,00 € 0,00 € 0,00 €
50 September 2026 0,00 € 0,00 € 0,00 € 0,00 €
51 Oktober 2026 0,00 € 0,00 € 0,00 € 0,00 €
52 November 2026 0,00 € 0,00 € 0,00 € 0,00 €
53 Dezember 2026 2.940,19 € 0,00 € 0,00 € 0,00 € 0,00 €
@@ -0,0 +1,58 @@
Datum,Zähler Haus,Zähler Netz,,Zähler Auto,Zähler Solar 1,Zähler Solar 2,Haus Verbrauch,Netz Verbrauch,Auto Verbrauch,Solar Erzeugung 1,Solar Erzeugung 2,Solar Erzeugung,Netz Einsparung,Anlage Eigenverbrauch,€/kWh,Verbrauchskosten,Ersparnis,Kosten,Jahreskosten,Jahresersparnis
Total,,,,175096kWh,229070kWh,117587kWh,51909kWh,43541kWh,5002kWh,10731kWh,5750kWh,16481kWh,8368kWh,8113kWh,"0,31 €",16.612 €,2.783 €,13.829 €,,
September 2022,0kWh,0kWh,,,0kWh,0kWh,,,,,,,,,"0,16 €","0,00 €","0,00 €","0,00 €",,
Oktober 2022,411kWh,416kWh,,,76kWh,0kWh,411kWh,416kWh,,76kWh,0kWh,76kWh,-5kWh,81kWh,"0,16 €","65,76 €","-0,80 €","66,56 €",,
November 2022,1153kWh,1248kWh,,,162kWh,0kWh,742kWh,832kWh,,86kWh,0kWh,86kWh,-90kWh,176kWh,"0,16 €","118,72 €","-14,40 €","133,12 €",,
Dezember 2022,1968kWh,2197kWh,,,212kWh,0kWh,815kWh,949kWh,,50kWh,0kWh,50kWh,-134kWh,184kWh,"0,16 €","130,40 €","-21,44 €","151,84 €","351,52 €","-36,64 €"
Januar 2023,3006kWh,3367kWh,,,271kWh,0kWh,1038kWh,1170kWh,,59kWh,0kWh,59kWh,-132kWh,191kWh,"0,44 €","456,72 €","-58,08 €","514,80 €",,
Februar 2023,3863kWh,4240kWh,,,409kWh,7kWh,857kWh,873kWh,,138kWh,7kWh,145kWh,-16kWh,161kWh,"0,44 €","377,08 €","-7,04 €","384,12 €",,
März 2023,4664kWh,4878kWh,,,620kWh,148kWh,801kWh,638kWh,,211kWh,141kWh,352kWh,163kWh,189kWh,"0,44 €","352,44 €","71,72 €","280,72 €",,
April 2023,5536kWh,5545kWh,,,845kWh,306kWh,872kWh,667kWh,,225kWh,158kWh,383kWh,205kWh,178kWh,"0,44 €","383,68 €","90,20 €","293,48 €",,
Mai 2023,6995kWh,6663kWh,,3755kWh,1147kWh,527kWh,1459kWh,1118kWh,3755kWh,302kWh,221kWh,523kWh,341kWh,182kWh,"0,37 €","540,71 €","126,37 €","414,33 €",,
Juni 2023,8721kWh,7801kWh,,3960kWh,1595kWh,835kWh,1726kWh,1138kWh,205kWh,448kWh,308kWh,756kWh,588kWh,168kWh,"0,37 €","639,66 €","217,91 €","421,74 €",,
,,,,,,,,,,,,,,,,,,,,
Juli 2023,10080kWh,8740kWh,,3990kWh,2000kWh,1059kWh,1359kWh,939kWh,30kWh,405kWh,224kWh,629kWh,420kWh,209kWh,"0,37 €","503,65 €","155,65 €","347,99 €",,
August 2023,11564kWh,9725kWh,,4025kWh,2460kWh,1288kWh,1484kWh,985kWh,35kWh,460kWh,229kWh,689kWh,499kWh,190kWh,"0,37 €","549,97 €","184,93 €","365,04 €",,
September 2023,12592kWh,10352kWh,,4051kWh,2878kWh,1465kWh,1028kWh,627kWh,26kWh,418kWh,177kWh,595kWh,401kWh,194kWh,"0,37 €","380,98 €","148,61 €","232,37 €",,
Oktober 2023,13446kWh,11099kWh,,4108kWh,3074kWh,1557kWh,854kWh,747kWh,57kWh,196kWh,92kWh,288kWh,107kWh,181kWh,"0,37 €","316,49 €","39,65 €","276,84 €",,
November 2023,14457kWh,12189kWh,,4223kWh,3142kWh,1588kWh,1011kWh,1090kWh,115kWh,68kWh,31kWh,99kWh,-79kWh,178kWh,"0,27 €","273,17 €","-21,35 €","294,52 €",,
Dezember 2023,15513kWh,13353kWh,,4350kWh,3192kWh,1603kWh,1056kWh,1164kWh,127kWh,50kWh,15kWh,65kWh,-108kWh,173kWh,"0,27 €","285,33 €","-29,18 €","314,51 €","4.140,46 €","919,41 €"
Januar 2024,16735kWh,14674kWh,,4480kWh,3261kWh,1624kWh,1222kWh,1321kWh,130kWh,69kWh,21kWh,90kWh,-99kWh,189kWh,"0,27 €","330,18 €","-26,75 €","356,93 €",,
Februar 2024,17760kWh,15650kWh,,4580kWh,3420kWh,1695kWh,1025kWh,976kWh,100kWh,159kWh,71kWh,230kWh,49kWh,181kWh,"0,27 €","276,96 €","13,24 €","263,72 €",,
März 2024,18903kWh,16561kWh,,4644kWh,3696kWh,1854kWh,1143kWh,911kWh,64kWh,276kWh,159kWh,435kWh,232kWh,203kWh,"0,27 €","308,61 €","62,64 €","245,97 €",,
April 2024,19992kWh,17303kWh,,4755kWh,4032kWh,2051kWh,1089kWh,742kWh,111kWh,336kWh,197kWh,533kWh,347kWh,186kWh,"0,27 €","294,03 €","93,69 €","200,34 €",,
Mai 2024,21031kWh,17912kWh,,4786kWh,4419kWh,2292kWh,1039kWh,609kWh,31kWh,387kWh,241kWh,628kWh,430kWh,198kWh,"0,27 €","280,53 €","116,10 €","164,43 €",,
Juni 2024,22247kWh,18653kWh,,4818kWh,4825kWh,2540kWh,1216kWh,741kWh,32kWh,406kWh,248kWh,654kWh,475kWh,179kWh,"0,27 €","328,32 €","128,25 €","200,07 €",,
Juli 2024,23954kWh,19865kWh,,4849kWh,5301kWh,2758kWh,1707kWh,1212kWh,31kWh,476kWh,218kWh,694kWh,495kWh,199kWh,"0,27 €","460,89 €","133,65 €","327,24 €",,
August 2024,25707kWh,21106kWh,,4894kWh,5762kWh,3008kWh,1753kWh,1241kWh,45kWh,461kWh,250kWh,711kWh,512kWh,199kWh,"0,27 €","473,31 €","138,24 €","335,07 €",,
September 2024,27025kWh,22178kWh,,4974kWh,6069kWh,3133kWh,1318kWh,1072kWh,80kWh,307kWh,125kWh,432kWh,246kWh,186kWh,"0,27 €","355,86 €","66,42 €","289,44 €",,
Oktober 2024,28089kWh,23218kWh,,4974kWh,6217kWh,3200kWh,1064kWh,1040kWh,0kWh,148kWh,67kWh,215kWh,24kWh,191kWh,"0,27 €","287,28 €","6,48 €","280,80 €",,
November 2024,29229kWh,24441kWh,,4974kWh,6279kWh,3231kWh,1140kWh,1223kWh,0kWh,62kWh,31kWh,93kWh,-83kWh,176kWh,"0,27 €","307,80 €","-22,41 €","330,21 €",,
Dezember 2024,30248kWh,25570kWh,,4974kWh,6320kWh,3253kWh,1019kWh,1129kWh,0kWh,41kWh,22kWh,63kWh,-110kWh,173kWh,"0,27 €","275,13 €","-29,70 €","304,83 €","3.299,05 €","679,85 €"
Januar 2025,31265kWh,26680kWh,,4974kWh,6445kWh,3302kWh,1017kWh,1110kWh,0kWh,125kWh,49kWh,174kWh,-93kWh,267kWh,"0,36 €","366,12 €","-33,48 €","399,60 €",,
Februar 2025,32263kWh,27602kWh,,4974kWh,6554kWh,3358kWh,998kWh,922kWh,0kWh,109kWh,56kWh,165kWh,76kWh,89kWh,"0,36 €","359,28 €","27,36 €","331,92 €",,
März 2025,33234kWh,28396kWh,,4982kWh,6817kWh,3486kWh,971kWh,794kWh,8kWh,263kWh,128kWh,391kWh,177kWh,214kWh,"0,36 €","349,56 €","63,72 €","285,84 €",,
April 2025,34188kWh,28911kWh,,4982kWh,7213kWh,3699kWh,954kWh,515kWh,0kWh,396kWh,213kWh,609kWh,439kWh,170kWh,"0,36 €","343,44 €","158,04 €","185,40 €",,
Mai 2025,35645kWh,29985kWh,,4996kWh,7463kWh,3985kWh,1457kWh,1074kWh,14kWh,250kWh,286kWh,536kWh,383kWh,153kWh,"0,36 €","524,52 €","137,88 €","386,64 €",,
Juni 2025,37288kWh,31095kWh,,5002kWh,8051kWh,4175kWh,1643kWh,1110kWh,6kWh,588kWh,190kWh,778kWh,533kWh,245kWh,"0,36 €","591,48 €","191,88 €","399,60 €",,
Juli 2025,38980kWh,32421kWh,,5002kWh,8410kWh,4350kWh,1692kWh,1326kWh,0kWh,359kWh,175kWh,534kWh,366kWh,168kWh,"0,36 €","609,12 €","131,76 €","477,36 €",,
August 2025,41060kWh,34050kWh,,5002kWh,8878kWh,4554kWh,2080kWh,1629kWh,0kWh,468kWh,204kWh,672kWh,451kWh,221kWh,"0,36 €","748,80 €","162,36 €","586,44 €",,
September 2025,42242kWh,35041kWh,,5002kWh,9151kWh,4658kWh,1182kWh,991kWh,0kWh,273kWh,104kWh,377kWh,191kWh,186kWh,"0,36 €","425,52 €","68,76 €","356,76 €",,
Oktober 2025,43520kWh,36230kWh,,5002kWh,9330kWh,4770kWh,1278kWh,1189kWh,0kWh,179kWh,112kWh,291kWh,89kWh,202kWh,"0,36 €","460,08 €","32,04 €","428,04 €",,
November 2025,44695kWh,37424kWh,,5002kWh,9403kWh,4843kWh,1175kWh,1194kWh,0kWh,73kWh,73kWh,146kWh,-19kWh,165kWh,"0,36 €","423,00 €","-6,84 €","429,84 €",,
Dezember 2025,45909kWh,38744kWh,,5002kWh,9443kWh,4880kWh,1214kWh,1320kWh,0kWh,40kWh,37kWh,77kWh,-106kWh,183kWh,"0,36 €","437,04 €","-38,16 €","475,20 €","4.742,64 €","895,32 €"
Januar 2026,47200kWh,40086kWh,,5002kWh,9527kWh,4941kWh,1291kWh,1342kWh,0kWh,84kWh,61kWh,145kWh,-51kWh,196kWh,"0,27 €","348,57 €","-13,77 €","362,34 €",,
Februar 2026,48300kWh,41090kWh,,5002kWh,9689kWh,5089kWh,1100kWh,1004kWh,0kWh,162kWh,148kWh,310kWh,96kWh,214kWh,"0,27 €","297,00 €","25,92 €","271,08 €",,
März 2026,49499kWh,42003kWh,,5002kWh,9973kWh,5251kWh,1199kWh,913kWh,0kWh,284kWh,162kWh,446kWh,286kWh,160kWh,"0,27 €","323,73 €","77,22 €","246,51 €",,
April 2026,50588kWh,42714kWh,,5002kWh,10308kWh,5474kWh,1089kWh,711kWh,0kWh,335kWh,223kWh,558kWh,378kWh,180kWh,"0,27 €","294,03 €","102,06 €","191,97 €",,
Mai 2026,51909kWh,43541kWh,,5002kWh,10731kWh,5750kWh,1321kWh,827kWh,0kWh,423kWh,276kWh,699kWh,494kWh,205kWh,"0,27 €","356,67 €","133,38 €","223,29 €",,
Juni 2026,,,,,,,,,,,,0kWh,0kWh,0kWh,"0,27 €","0,00 €","0,00 €","0,00 €",,
Juli 2026,,,,,,,,,,,,0kWh,0kWh,0kWh,"0,27 €","0,00 €","0,00 €","0,00 €",,
August 2026,,,,,,,,,,,,0kWh,0kWh,0kWh,"0,27 €","0,00 €","0,00 €","0,00 €",,
September 2026,,,,,,,,,,,,0kWh,0kWh,0kWh,"0,27 €","0,00 €","0,00 €","0,00 €",,
Oktober 2026,,,,,,,,,,,,0kWh,0kWh,0kWh,"0,27 €","0,00 €","0,00 €","0,00 €",,
November 2026,,,,,,,,,,,,0kWh,0kWh,0kWh,"0,27 €","0,00 €","0,00 €","0,00 €",,
Dezember 2026,,,,,,,,,,,,0kWh,0kWh,0kWh,"0,27 €","0,00 €","0,00 €","0,00 €","1.295,19 €","324,81 €"
,,,,,,,,,,,,0kWh,0kWh,0kWh,"0,27 €","0,00 €","0,00 €","0,00 €",,
,,,,,,,,,,,,0kWh,0kWh,0kWh,,"0,00 €","0,00 €","0,00 €",,
,,,,,,,,,,,,0kWh,0kWh,0kWh,,"0,00 €","0,00 €","0,00 €",,
1 Datum Zähler Haus Zähler Netz Zähler Auto Zähler Solar 1 Zähler Solar 2 Haus Verbrauch Netz Verbrauch Auto Verbrauch Solar Erzeugung 1 Solar Erzeugung 2 Solar Erzeugung Netz Einsparung Anlage Eigenverbrauch €/kWh Verbrauchskosten Ersparnis Kosten Jahreskosten Jahresersparnis
2 Total 175096kWh 229070kWh 117587kWh 51909kWh 43541kWh 5002kWh 10731kWh 5750kWh 16481kWh 8368kWh 8113kWh 0,31 € 16.612 € 2.783 € 13.829 €
3 September 2022 0kWh 0kWh 0kWh 0kWh 0,16 € 0,00 € 0,00 € 0,00 €
4 Oktober 2022 411kWh 416kWh 76kWh 0kWh 411kWh 416kWh 76kWh 0kWh 76kWh -5kWh 81kWh 0,16 € 65,76 € -0,80 € 66,56 €
5 November 2022 1153kWh 1248kWh 162kWh 0kWh 742kWh 832kWh 86kWh 0kWh 86kWh -90kWh 176kWh 0,16 € 118,72 € -14,40 € 133,12 €
6 Dezember 2022 1968kWh 2197kWh 212kWh 0kWh 815kWh 949kWh 50kWh 0kWh 50kWh -134kWh 184kWh 0,16 € 130,40 € -21,44 € 151,84 € 351,52 € -36,64 €
7 Januar 2023 3006kWh 3367kWh 271kWh 0kWh 1038kWh 1170kWh 59kWh 0kWh 59kWh -132kWh 191kWh 0,44 € 456,72 € -58,08 € 514,80 €
8 Februar 2023 3863kWh 4240kWh 409kWh 7kWh 857kWh 873kWh 138kWh 7kWh 145kWh -16kWh 161kWh 0,44 € 377,08 € -7,04 € 384,12 €
9 März 2023 4664kWh 4878kWh 620kWh 148kWh 801kWh 638kWh 211kWh 141kWh 352kWh 163kWh 189kWh 0,44 € 352,44 € 71,72 € 280,72 €
10 April 2023 5536kWh 5545kWh 845kWh 306kWh 872kWh 667kWh 225kWh 158kWh 383kWh 205kWh 178kWh 0,44 € 383,68 € 90,20 € 293,48 €
11 Mai 2023 6995kWh 6663kWh 3755kWh 1147kWh 527kWh 1459kWh 1118kWh 3755kWh 302kWh 221kWh 523kWh 341kWh 182kWh 0,37 € 540,71 € 126,37 € 414,33 €
12 Juni 2023 8721kWh 7801kWh 3960kWh 1595kWh 835kWh 1726kWh 1138kWh 205kWh 448kWh 308kWh 756kWh 588kWh 168kWh 0,37 € 639,66 € 217,91 € 421,74 €
13
14 Juli 2023 10080kWh 8740kWh 3990kWh 2000kWh 1059kWh 1359kWh 939kWh 30kWh 405kWh 224kWh 629kWh 420kWh 209kWh 0,37 € 503,65 € 155,65 € 347,99 €
15 August 2023 11564kWh 9725kWh 4025kWh 2460kWh 1288kWh 1484kWh 985kWh 35kWh 460kWh 229kWh 689kWh 499kWh 190kWh 0,37 € 549,97 € 184,93 € 365,04 €
16 September 2023 12592kWh 10352kWh 4051kWh 2878kWh 1465kWh 1028kWh 627kWh 26kWh 418kWh 177kWh 595kWh 401kWh 194kWh 0,37 € 380,98 € 148,61 € 232,37 €
17 Oktober 2023 13446kWh 11099kWh 4108kWh 3074kWh 1557kWh 854kWh 747kWh 57kWh 196kWh 92kWh 288kWh 107kWh 181kWh 0,37 € 316,49 € 39,65 € 276,84 €
18 November 2023 14457kWh 12189kWh 4223kWh 3142kWh 1588kWh 1011kWh 1090kWh 115kWh 68kWh 31kWh 99kWh -79kWh 178kWh 0,27 € 273,17 € -21,35 € 294,52 €
19 Dezember 2023 15513kWh 13353kWh 4350kWh 3192kWh 1603kWh 1056kWh 1164kWh 127kWh 50kWh 15kWh 65kWh -108kWh 173kWh 0,27 € 285,33 € -29,18 € 314,51 € 4.140,46 € 919,41 €
20 Januar 2024 16735kWh 14674kWh 4480kWh 3261kWh 1624kWh 1222kWh 1321kWh 130kWh 69kWh 21kWh 90kWh -99kWh 189kWh 0,27 € 330,18 € -26,75 € 356,93 €
21 Februar 2024 17760kWh 15650kWh 4580kWh 3420kWh 1695kWh 1025kWh 976kWh 100kWh 159kWh 71kWh 230kWh 49kWh 181kWh 0,27 € 276,96 € 13,24 € 263,72 €
22 März 2024 18903kWh 16561kWh 4644kWh 3696kWh 1854kWh 1143kWh 911kWh 64kWh 276kWh 159kWh 435kWh 232kWh 203kWh 0,27 € 308,61 € 62,64 € 245,97 €
23 April 2024 19992kWh 17303kWh 4755kWh 4032kWh 2051kWh 1089kWh 742kWh 111kWh 336kWh 197kWh 533kWh 347kWh 186kWh 0,27 € 294,03 € 93,69 € 200,34 €
24 Mai 2024 21031kWh 17912kWh 4786kWh 4419kWh 2292kWh 1039kWh 609kWh 31kWh 387kWh 241kWh 628kWh 430kWh 198kWh 0,27 € 280,53 € 116,10 € 164,43 €
25 Juni 2024 22247kWh 18653kWh 4818kWh 4825kWh 2540kWh 1216kWh 741kWh 32kWh 406kWh 248kWh 654kWh 475kWh 179kWh 0,27 € 328,32 € 128,25 € 200,07 €
26 Juli 2024 23954kWh 19865kWh 4849kWh 5301kWh 2758kWh 1707kWh 1212kWh 31kWh 476kWh 218kWh 694kWh 495kWh 199kWh 0,27 € 460,89 € 133,65 € 327,24 €
27 August 2024 25707kWh 21106kWh 4894kWh 5762kWh 3008kWh 1753kWh 1241kWh 45kWh 461kWh 250kWh 711kWh 512kWh 199kWh 0,27 € 473,31 € 138,24 € 335,07 €
28 September 2024 27025kWh 22178kWh 4974kWh 6069kWh 3133kWh 1318kWh 1072kWh 80kWh 307kWh 125kWh 432kWh 246kWh 186kWh 0,27 € 355,86 € 66,42 € 289,44 €
29 Oktober 2024 28089kWh 23218kWh 4974kWh 6217kWh 3200kWh 1064kWh 1040kWh 0kWh 148kWh 67kWh 215kWh 24kWh 191kWh 0,27 € 287,28 € 6,48 € 280,80 €
30 November 2024 29229kWh 24441kWh 4974kWh 6279kWh 3231kWh 1140kWh 1223kWh 0kWh 62kWh 31kWh 93kWh -83kWh 176kWh 0,27 € 307,80 € -22,41 € 330,21 €
31 Dezember 2024 30248kWh 25570kWh 4974kWh 6320kWh 3253kWh 1019kWh 1129kWh 0kWh 41kWh 22kWh 63kWh -110kWh 173kWh 0,27 € 275,13 € -29,70 € 304,83 € 3.299,05 € 679,85 €
32 Januar 2025 31265kWh 26680kWh 4974kWh 6445kWh 3302kWh 1017kWh 1110kWh 0kWh 125kWh 49kWh 174kWh -93kWh 267kWh 0,36 € 366,12 € -33,48 € 399,60 €
33 Februar 2025 32263kWh 27602kWh 4974kWh 6554kWh 3358kWh 998kWh 922kWh 0kWh 109kWh 56kWh 165kWh 76kWh 89kWh 0,36 € 359,28 € 27,36 € 331,92 €
34 März 2025 33234kWh 28396kWh 4982kWh 6817kWh 3486kWh 971kWh 794kWh 8kWh 263kWh 128kWh 391kWh 177kWh 214kWh 0,36 € 349,56 € 63,72 € 285,84 €
35 April 2025 34188kWh 28911kWh 4982kWh 7213kWh 3699kWh 954kWh 515kWh 0kWh 396kWh 213kWh 609kWh 439kWh 170kWh 0,36 € 343,44 € 158,04 € 185,40 €
36 Mai 2025 35645kWh 29985kWh 4996kWh 7463kWh 3985kWh 1457kWh 1074kWh 14kWh 250kWh 286kWh 536kWh 383kWh 153kWh 0,36 € 524,52 € 137,88 € 386,64 €
37 Juni 2025 37288kWh 31095kWh 5002kWh 8051kWh 4175kWh 1643kWh 1110kWh 6kWh 588kWh 190kWh 778kWh 533kWh 245kWh 0,36 € 591,48 € 191,88 € 399,60 €
38 Juli 2025 38980kWh 32421kWh 5002kWh 8410kWh 4350kWh 1692kWh 1326kWh 0kWh 359kWh 175kWh 534kWh 366kWh 168kWh 0,36 € 609,12 € 131,76 € 477,36 €
39 August 2025 41060kWh 34050kWh 5002kWh 8878kWh 4554kWh 2080kWh 1629kWh 0kWh 468kWh 204kWh 672kWh 451kWh 221kWh 0,36 € 748,80 € 162,36 € 586,44 €
40 September 2025 42242kWh 35041kWh 5002kWh 9151kWh 4658kWh 1182kWh 991kWh 0kWh 273kWh 104kWh 377kWh 191kWh 186kWh 0,36 € 425,52 € 68,76 € 356,76 €
41 Oktober 2025 43520kWh 36230kWh 5002kWh 9330kWh 4770kWh 1278kWh 1189kWh 0kWh 179kWh 112kWh 291kWh 89kWh 202kWh 0,36 € 460,08 € 32,04 € 428,04 €
42 November 2025 44695kWh 37424kWh 5002kWh 9403kWh 4843kWh 1175kWh 1194kWh 0kWh 73kWh 73kWh 146kWh -19kWh 165kWh 0,36 € 423,00 € -6,84 € 429,84 €
43 Dezember 2025 45909kWh 38744kWh 5002kWh 9443kWh 4880kWh 1214kWh 1320kWh 0kWh 40kWh 37kWh 77kWh -106kWh 183kWh 0,36 € 437,04 € -38,16 € 475,20 € 4.742,64 € 895,32 €
44 Januar 2026 47200kWh 40086kWh 5002kWh 9527kWh 4941kWh 1291kWh 1342kWh 0kWh 84kWh 61kWh 145kWh -51kWh 196kWh 0,27 € 348,57 € -13,77 € 362,34 €
45 Februar 2026 48300kWh 41090kWh 5002kWh 9689kWh 5089kWh 1100kWh 1004kWh 0kWh 162kWh 148kWh 310kWh 96kWh 214kWh 0,27 € 297,00 € 25,92 € 271,08 €
46 März 2026 49499kWh 42003kWh 5002kWh 9973kWh 5251kWh 1199kWh 913kWh 0kWh 284kWh 162kWh 446kWh 286kWh 160kWh 0,27 € 323,73 € 77,22 € 246,51 €
47 April 2026 50588kWh 42714kWh 5002kWh 10308kWh 5474kWh 1089kWh 711kWh 0kWh 335kWh 223kWh 558kWh 378kWh 180kWh 0,27 € 294,03 € 102,06 € 191,97 €
48 Mai 2026 51909kWh 43541kWh 5002kWh 10731kWh 5750kWh 1321kWh 827kWh 0kWh 423kWh 276kWh 699kWh 494kWh 205kWh 0,27 € 356,67 € 133,38 € 223,29 €
49 Juni 2026 0kWh 0kWh 0kWh 0,27 € 0,00 € 0,00 € 0,00 €
50 Juli 2026 0kWh 0kWh 0kWh 0,27 € 0,00 € 0,00 € 0,00 €
51 August 2026 0kWh 0kWh 0kWh 0,27 € 0,00 € 0,00 € 0,00 €
52 September 2026 0kWh 0kWh 0kWh 0,27 € 0,00 € 0,00 € 0,00 €
53 Oktober 2026 0kWh 0kWh 0kWh 0,27 € 0,00 € 0,00 € 0,00 €
54 November 2026 0kWh 0kWh 0kWh 0,27 € 0,00 € 0,00 € 0,00 €
55 Dezember 2026 0kWh 0kWh 0kWh 0,27 € 0,00 € 0,00 € 0,00 € 1.295,19 € 324,81 €
56 0kWh 0kWh 0kWh 0,27 € 0,00 € 0,00 € 0,00 €
57 0kWh 0kWh 0kWh 0,00 € 0,00 € 0,00 €
58 0kWh 0kWh 0kWh 0,00 € 0,00 € 0,00 €
+51
View File
@@ -0,0 +1,51 @@
Datum,Zähler Wasser,Wasserverbrauch,€/m³,Kosten,Jahreskosten
November 2022,820,0,,,
Dezember 2022,834,14,"5,00 €",70 €,70 €
Januar 2023,848,14,"5,00 €",70 €,
Februar 2023,861,13,"5,00 €",65 €,
März 2023,2,12,"5,00 €",60 €,
April 2023,15,13,"5,00 €",65 €,
Mai 2023,50,35,"5,00 €",175 €,
Juni 2023,75,25,"5,00 €",125 €,
Juli 2023,93,18,"5,00 €",90 €,
August 2023,113,20,"5,00 €",100 €,
September 2023,132,19,"5,00 €",95 €,
Oktober 2023,144,12,"5,00 €",60 €,
November 2023,155,11,"5,00 €",55 €,
Dezember 2023,167,12,"5,00 €",60 €,1.020 €
Januar 2024,181,14,"5,00 €",70 €,
Februar 2024,195,14,"5,00 €",70 €,
März 2024,212,17,"5,00 €",85 €,
April 2024,251,39,"5,00 €",195 €,
Mai 2024,266,15,"5,00 €",75 €,
Juni 2024,297,31,"5,00 €",155 €,
Juli 2024,312,15,"5,00 €",75 €,
August 2024,331,19,"5,00 €",95 €,
September 2024,347,16,"5,00 €",80 €,
Oktober 2024,360,13,"5,00 €",65 €,
November 2024,377,17,"5,00 €",85 €,
Dezember 2024,395,18,"5,00 €",90 €,1.140 €
Januar 2025,412,17,"5,00 €",85 €,
Februar 2025,429,17,"5,00 €",85 €,
März 2025,443,14,"5,00 €",70 €,
April 2025,458,15,"5,00 €",75 €,
Mai 2025,475,17,"5,00 €",85 €,
Juni 2025,511,36,"5,00 €",180 €,
Juli 2025,529,18,"5,00 €",90 €,
August 2025,547,18,"5,00 €",90 €,
September 2025,563,16,"5,00 €",80 €,
Oktober 2025,580,17,"5,00 €",85 €,
November 2025,593,13,"5,00 €",65 €,
Dezember 2025,608,15,"5,00 €",75 €,1.065 €
Januar 2026,622,14,"7,00 €",98 €,
Februar 2026,637,15,"7,00 €",105 €,
März 2026,652,15,"7,00 €",105 €,
April 2026,671,19,"7,00 €",133 €,
Mai 2026,687,16,"7,00 €",112 €,
Juni 2026,,,"7,00 €",0 €,
Juli 2026,,,"7,00 €",0 €,
August 2026,,,"7,00 €",0 €,
September 2026,,,"7,00 €",0 €,
Oktober 2026,,,"7,00 €",0 €,
November 2026,,,"7,00 €",0 €,
Dezember 2026,,,"7,00 €",0 €,
1 Datum Zähler Wasser Wasserverbrauch €/m³ Kosten Jahreskosten
2 November 2022 820 0
3 Dezember 2022 834 14 5,00 € 70 € 70 €
4 Januar 2023 848 14 5,00 € 70 €
5 Februar 2023 861 13 5,00 € 65 €
6 März 2023 2 12 5,00 € 60 €
7 April 2023 15 13 5,00 € 65 €
8 Mai 2023 50 35 5,00 € 175 €
9 Juni 2023 75 25 5,00 € 125 €
10 Juli 2023 93 18 5,00 € 90 €
11 August 2023 113 20 5,00 € 100 €
12 September 2023 132 19 5,00 € 95 €
13 Oktober 2023 144 12 5,00 € 60 €
14 November 2023 155 11 5,00 € 55 €
15 Dezember 2023 167 12 5,00 € 60 € 1.020 €
16 Januar 2024 181 14 5,00 € 70 €
17 Februar 2024 195 14 5,00 € 70 €
18 März 2024 212 17 5,00 € 85 €
19 April 2024 251 39 5,00 € 195 €
20 Mai 2024 266 15 5,00 € 75 €
21 Juni 2024 297 31 5,00 € 155 €
22 Juli 2024 312 15 5,00 € 75 €
23 August 2024 331 19 5,00 € 95 €
24 September 2024 347 16 5,00 € 80 €
25 Oktober 2024 360 13 5,00 € 65 €
26 November 2024 377 17 5,00 € 85 €
27 Dezember 2024 395 18 5,00 € 90 € 1.140 €
28 Januar 2025 412 17 5,00 € 85 €
29 Februar 2025 429 17 5,00 € 85 €
30 März 2025 443 14 5,00 € 70 €
31 April 2025 458 15 5,00 € 75 €
32 Mai 2025 475 17 5,00 € 85 €
33 Juni 2025 511 36 5,00 € 180 €
34 Juli 2025 529 18 5,00 € 90 €
35 August 2025 547 18 5,00 € 90 €
36 September 2025 563 16 5,00 € 80 €
37 Oktober 2025 580 17 5,00 € 85 €
38 November 2025 593 13 5,00 € 65 €
39 Dezember 2025 608 15 5,00 € 75 € 1.065 €
40 Januar 2026 622 14 7,00 € 98 €
41 Februar 2026 637 15 7,00 € 105 €
42 März 2026 652 15 7,00 € 105 €
43 April 2026 671 19 7,00 € 133 €
44 Mai 2026 687 16 7,00 € 112 €
45 Juni 2026 7,00 € 0 €
46 Juli 2026 7,00 € 0 €
47 August 2026 7,00 € 0 €
48 September 2026 7,00 € 0 €
49 Oktober 2026 7,00 € 0 €
50 November 2026 7,00 € 0 €
51 Dezember 2026 7,00 € 0 €
+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
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Core\MeterVault.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,18 @@
namespace MeterVault.Integration.Tests;
[Collection("Timescale")]
public sealed class HealthEndpointTests(TimescaleFixture fx)
{
[Fact]
public async Task Healthz_returns_ok()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
using var response = await client.GetAsync(new Uri("/healthz", UriKind.Relative));
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("ok", body, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Respawn" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\App\MeterVault.App.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
namespace MeterVault.Integration.Tests;
/// <summary>
/// Boots the real ASP.NET Core app in-memory against the shared Timescale container.
/// Migrations are already applied by <see cref="TimescaleFixture"/>, so startup migration is off.
/// </summary>
public sealed class MeterVaultAppFactory(string connectionString) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:Default", connectionString);
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
[Collection("Timescale")]
public sealed class SchemaTests(TimescaleFixture fx)
{
[Fact]
public async Task Migrations_are_applied()
{
await using var ctx = fx.CreateContext();
var applied = (await ctx.Database.GetAppliedMigrationsAsync()).ToList();
Assert.Contains(applied, m => m.EndsWith("InitialSchema", StringComparison.Ordinal));
Assert.Contains(applied, m => m.EndsWith("TimescaleHypertables", StringComparison.Ordinal));
}
[Fact]
public async Task Reading_and_consumption_are_hypertables()
{
await using var ctx = fx.CreateContext();
var conn = ctx.Database.GetDbConnection();
await conn.OpenAsync();
var names = await QueryStringsAsync(conn,
"SELECT hypertable_name FROM timescaledb_information.hypertables ORDER BY hypertable_name;");
Assert.Equal(["consumption", "reading"], names);
}
[Fact]
public async Task Reading_has_a_compression_policy()
{
await using var ctx = fx.CreateContext();
var conn = ctx.Database.GetDbConnection();
await conn.OpenAsync();
// A compression/columnstore policy shows up as a scheduled job against the hypertable.
var jobs = await QueryStringsAsync(conn,
"SELECT hypertable_name FROM timescaledb_information.jobs " +
"WHERE proc_name IN ('policy_compression', 'policy_columnstore') AND hypertable_name = 'reading';");
Assert.Contains("reading", jobs);
}
private static async Task<List<string>> QueryStringsAsync(DbConnection conn, string sql)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var results = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(reader.GetString(0));
}
return results;
}
}
@@ -0,0 +1,45 @@
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
namespace MeterVault.Integration.Tests;
/// <summary>
/// Spins up exactly one TimescaleDB container per test run (shared via the "Timescale"
/// collection) and applies migrations once. Tests reuse it and isolate themselves with Respawn.
/// The image tag is pinned: the compression DDL (add_compression_policy) was renamed toward
/// add_columnstore_policy in newer Timescale, so a floating tag would risk breaking migrations.
/// </summary>
public sealed class TimescaleFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder("timescale/timescaledb:2.17.2-pg16")
.WithDatabase("metervault")
.WithUsername("metervault")
.WithPassword("metervault")
.Build();
public string ConnectionString => _db.GetConnectionString();
public MeterVaultDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<MeterVaultDbContext>()
.UseNpgsql(ConnectionString, npgsql =>
npgsql.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
.UseSnakeCaseNamingConvention()
.Options;
return new MeterVaultDbContext(options);
}
public async Task InitializeAsync()
{
await _db.StartAsync();
await using var ctx = CreateContext();
await ctx.Database.MigrateAsync();
}
public async Task DisposeAsync() => await _db.DisposeAsync();
}
/// <summary>Binds the shared <see cref="TimescaleFixture"/> to all tests in the "Timescale" collection.</summary>
[CollectionDefinition("Timescale")]
public sealed class TimescaleCollection : ICollectionFixture<TimescaleFixture>;