From 1282acf82ce70a080c70acaf332544cf07a0ed00 Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Tue, 14 Jul 2026 09:52:11 +0200 Subject: [PATCH] =?UTF-8?q?SDD=20=C2=A78=20panels=20(PV/oil/meter-detail)?= =?UTF-8?q?=20+=20fix=20reference-data-in-Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the SDD §8 dashboard views that were deferred at the M5 boundary, and fix a shipping bug that left the Docker demo empty. Bug: "Load reference data" created meters/tank/tariffs but imported zero readings in Docker. Root cause: sampledata/ was excluded by .dockerignore and never copied into the build stage, so the App csproj's linked Content glob resolved to nothing at publish time; ReferenceDataImporter then silently skipped the missing CSVs after already writing its marker meter, leaving the DB permanently "loaded" but empty. - .dockerignore: stop excluding sampledata/ - Dockerfile: COPY sampledata/ into the build stage - ReferenceDataImporter: fail-fast (validate CSVs exist before the marker meter) and throw instead of silently skipping a missing file - Program.cs + MeterVaultOptions: opt-in MeterVault__SeedReferenceData (compose METERVAULT_SEED=true) for a one-command populated demo New SDD §8 panels (read models in Infrastructure/Dashboard, Blazor pages): - §8.4 Solar/PV (/solar): generation from GenerationCounter meters; self-consumption / autarky % / self-consumption % / savings derived from meters tagged total_load & grid_import via Meter.Meta role config (MeterRoles/MeterMeta) — nothing hardcoded by name. - §8.5 Oil/consumable (/consumables): tank level (cm→L calibrated), fill gauge, deliveries log, burner runtime, effective L/h (fixed/empirical), forecast-to-empty, tariff cost, monthly series. - §8.6 Meter detail (/meters/{id}): raw readings, normalized consumption, source status, tariff timeline, events, measured-vs-estimated markers. - Reusable SeriesChart component; nav links; Meters list rows link to detail. Tests: MeterMetaTests (Core, +10); DashboardRenderTests extended to assert the three panel services compute real figures and the new routes render (108 total, all green). Live-verified in Docker: seed imports 302 readings / 347 consumption rows; panels render (generation 16,481 kWh, oil 3,967 L) cross-checking the DB. Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK --- .dockerignore | 1 - CLAUDE.md | 2 +- README.md | 7 +- deploy/Dockerfile | 4 +- deploy/docker-compose.yml | 3 + src/App/Components/Layout/NavMenu.razor | 2 + src/App/Components/Pages/Consumables.razor | 186 +++++++++++++++ src/App/Components/Pages/MeterDetail.razor | 216 ++++++++++++++++++ src/App/Components/Pages/Meters.razor | 2 +- src/App/Components/Pages/Solar.razor | 149 ++++++++++++ src/App/Components/Shared/SeriesChart.razor | 47 ++++ src/App/Program.cs | 8 + src/Core/Domain/MeterMeta.cs | 78 +++++++ .../Dashboard/ConsumableModels.cs | 35 +++ .../Dashboard/ConsumableService.cs | 177 ++++++++++++++ .../Dashboard/MeterDetailModels.cs | 49 ++++ .../Dashboard/MeterDetailService.cs | 88 +++++++ src/Infrastructure/Dashboard/SolarModels.cs | 37 +++ src/Infrastructure/Dashboard/SolarService.cs | 116 ++++++++++ src/Infrastructure/DependencyInjection.cs | 3 + .../Import/ReferenceDataImporter.cs | 31 ++- .../Options/MeterVaultOptions.cs | 7 + tests/Core.Tests/MeterMetaTests.cs | 49 ++++ .../Integration.Tests/DashboardRenderTests.cs | 37 ++- 24 files changed, 1327 insertions(+), 7 deletions(-) create mode 100644 src/App/Components/Pages/Consumables.razor create mode 100644 src/App/Components/Pages/MeterDetail.razor create mode 100644 src/App/Components/Pages/Solar.razor create mode 100644 src/App/Components/Shared/SeriesChart.razor create mode 100644 src/Core/Domain/MeterMeta.cs create mode 100644 src/Infrastructure/Dashboard/ConsumableModels.cs create mode 100644 src/Infrastructure/Dashboard/ConsumableService.cs create mode 100644 src/Infrastructure/Dashboard/MeterDetailModels.cs create mode 100644 src/Infrastructure/Dashboard/MeterDetailService.cs create mode 100644 src/Infrastructure/Dashboard/SolarModels.cs create mode 100644 src/Infrastructure/Dashboard/SolarService.cs create mode 100644 tests/Core.Tests/MeterMetaTests.cs diff --git a/.dockerignore b/.dockerignore index 7bd8b49..b94eb33 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,6 @@ .git/ .github/ docs/ -sampledata/ tests/ **/*.user **/appsettings.*.Local.json diff --git a/CLAUDE.md b/CLAUDE.md index 2a22907..c6fc0dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 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: implemented (M0–M7).** The full solution is built and green — five projects, ~95 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). Remaining refinements (HA WebSocket push, dedicated PV/oil dashboard panels, full admin CRUD, full de-DE UI localization) are noted at the end of their milestone commits. +**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. Remaining refinements (HA WebSocket push, full admin write-CRUD, full de-DE UI localization) are noted at the end of their milestone commits. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo. ## Source of truth diff --git a/README.md b/README.md index 2bbc67f..b9dfbbf 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ full design. per meter; **cost categories** decoupled from energy types; meterless manual costs. - **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw. - **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/ - less" difference view, trends, meter list, one-click reference-data load, CSV dry-run. + less" difference view, trends, a **PV/Solar panel** (generation, self-consumption, autarky %, + savings), an **oil/consumable panel** (tank gauge, deliveries, burner runtime, effective L/h, + forecast-to-empty) and a **per-meter detail view** (raw readings, consumption, sources, tariff + timeline, events), one-click reference-data load, CSV dry-run. - **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik). - **JSON config export/import** for portability; Docker Compose + multi-arch image. @@ -31,6 +34,7 @@ full design. ```bash docker compose -f deploy/docker-compose.yml up -d # open http://localhost:8080 → Import → "Load reference data" for a populated demo +# ...or start pre-populated: METERVAULT_SEED=true docker compose -f deploy/docker-compose.yml up -d # API docs at http://localhost:8080/swagger ``` @@ -44,6 +48,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map | `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) | | `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy | | `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers | +| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) | The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it returns 401. Set at least one API key (or open it explicitly for a trusted network). diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 2dd1889..d803826 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -11,8 +11,10 @@ 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. +# Build & publish. sampledata/ must be present so the App csproj's linked Content glob +# (..\..\sampledata\*.csv) resolves at publish time — otherwise "Load reference data" ships empty. COPY src/ ./src/ +COPY sampledata/ ./sampledata/ 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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 64bc03a..6e6a077 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -33,6 +33,9 @@ services: MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin} MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR} MeterVault__Locale: ${METERVAULT_LOCALE:-en} + # Set true for a populated demo: loads the bundled Energiebilanz dataset on first start + # (idempotent). Leave false for a clean instance. + MeterVault__SeedReferenceData: ${METERVAULT_SEED:-false} # REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN): # MeterVault__ApiKeys__0: your-secret-key # MeterVault__AllowAnonymousApi: "true" diff --git a/src/App/Components/Layout/NavMenu.razor b/src/App/Components/Layout/NavMenu.razor index ed77e41..eb763b4 100644 --- a/src/App/Components/Layout/NavMenu.razor +++ b/src/App/Components/Layout/NavMenu.razor @@ -1,6 +1,8 @@ Overview Trends + Solar / PV + Oil / consumables Meters Import diff --git a/src/App/Components/Pages/Consumables.razor b/src/App/Components/Pages/Consumables.razor new file mode 100644 index 0000000..44a8254 --- /dev/null +++ b/src/App/Components/Pages/Consumables.razor @@ -0,0 +1,186 @@ +@page "/consumables" +@rendermode InteractiveServer +@inject ConsumableService ConsumablesSvc +@using MudBlazor + +MeterVault — Consumables + +
+ Oil / consumables + + Last 12 months + Last 24 months + Last 5 years + All time + +
+ +@if (_items is null) +{ + +} +else if (_items.Count == 0) +{ + + No consumable meters found. Add a meter with mode ConsumableBalance and a tank, or load the reference + data from Import. + +} +else +{ + @foreach (var item in _items) + { + + @item.Name + + + Tank level + + @(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—") + + + + @Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit + @if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm") + { + · @Format.Number(cm, 0) cm + } + @if (item.LevelAsOf is { } asOf) + { + · as of @asOf.ToString("yyyy-MM-dd") + } + + + + + + + Used (range) + @Format.Number(item.ConsumptionInRange, 0) @item.Unit + + + Burner runtime + @(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—") + + + Effective rate + + @if (item.FixedRate is { } fr) + { + @Format.Number(fr, 2) @item.Unit/h + } + else if (item.EffectiveRate is { } er) + { + @Format.Number(er, 2) @item.Unit/h + } + else + { + + } + + @(item.RateMode) + + + Cost (range) + @Format.Euro(item.CostInRange) + + + Forecast to empty + + @(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—") + @if (item.AveragePerDay is { } apd) + { + + (@Format.Number(apd, 1) @item.Unit/day) + + } + + + + + + + Consumption by month + + + + + Deliveries (@item.Deliveries.Count) + @if (item.Deliveries.Count == 0) + { + No deliveries recorded. + } + else + { +
+ + + DateAmount + + + @foreach (var delivery in item.Deliveries) + { + + @delivery.Time.ToString("yyyy-MM-dd") + @Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit) + + } + + +
+ } +
+
+
+ } +} + +@code { + private int _months = 60; + private bool _loading; + private IReadOnlyList? _items; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task OnRangeChanged(int months) + { + _months = months; + await LoadAsync(); + } + + private async Task LoadAsync() + { + if (_loading) + { + return; + } + + _loading = true; + _items = null; + try + { + var asOf = DateOnly.FromDateTime(DateTime.UtcNow); + var from = asOf.AddMonths(-_months); + _items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); + } + finally + { + _loading = false; + } + } + + private static IReadOnlyList ChartFor(ConsumableSummary item) + { + var points = item.Months + .Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption)) + .ToList(); + return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)]; + } + + private static Color FillColor(double fraction) => fraction switch + { + < 0.15 => Color.Error, + < 0.30 => Color.Warning, + _ => Color.Success, + }; +} diff --git a/src/App/Components/Pages/MeterDetail.razor b/src/App/Components/Pages/MeterDetail.razor new file mode 100644 index 0000000..44b8e7b --- /dev/null +++ b/src/App/Components/Pages/MeterDetail.razor @@ -0,0 +1,216 @@ +@page "/meters/{Id:int}" +@rendermode InteractiveServer +@inject MeterDetailService Details +@inject NavigationManager Nav +@using MudBlazor + +MeterVault — Meter + +@if (_detail is null) +{ + @if (_notFound) + { + Meter #@Id not found. Back to meters + } + else + { + + } +} +else +{ +
+ + @_detail.Name + @_detail.EnergyType + @_detail.Mode + @if (!_detail.IsActive) + { + retired + } +
+ + + + + Consumption + @Format.Number(_detail.TotalConsumption, 0) @_detail.Unit + + + @if (_detail.TotalGeneration != 0) + { + + + Generation + @Format.Number(_detail.TotalGeneration, 0) @_detail.Unit + + + } + + + Readings + @_detail.ReadingCount + + @(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—") + + + + + + Register span + + @(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") → + @(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—") + + baseline @Format.Number(_detail.InitialBaseline, 0) + + + + + + + @if (_detail.RecentReadings.Count == 0) + { + No raw readings. + } + else + { + Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). + + TimeValueQualityFlags + + @foreach (var r in _detail.RecentReadings) + { + + @r.Time.ToString("yyyy-MM-dd HH:mm") + @Format.Number(r.Value, 2) @_detail.Unit + @QualityChip(r.Quality) + @(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString()) + + } + + + } + + + + @if (_detail.RecentConsumption.Count == 0) + { + No normalized consumption yet. + } + else + { + Most recent @_detail.RecentConsumption.Count normalized deltas. + + TimeAmountKindQuality + + @foreach (var c in _detail.RecentConsumption) + { + + @c.Time.ToString("yyyy-MM-dd HH:mm") + @Format.Number(c.Amount, 2) @_detail.Unit + @c.Kind + @QualityChip(c.Quality) + + } + + + } + + + + @if (_detail.Events.Count == 0) + { + No events (swaps, deliveries, corrections). + } + else + { + + TimeTypeAmountPrev→NewNotes + + @foreach (var e in _detail.Events) + { + + @e.Time.ToString("yyyy-MM-dd") + @e.Type + @(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—") + @(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—") + @e.Notes + + } + + + } + + + + @if (_detail.Tariffs.Count == 0) + { + No applicable tariffs. + } + else + { + + ScopeComponentValueUnitFromTo + + @foreach (var t in _detail.Tariffs) + { + + @t.Scope @(t.ScopeId is { } id ? $"#{id}" : "") + @t.Component + @Format.Number(t.Value, 4) + @t.Unit + @t.ValidFrom.ToString("yyyy-MM-dd") + @(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open") + + } + + + } + + + + @if (_detail.Sources.Count == 0) + { + No ingest sources bound to this meter. + } + else + { + + TypeEnabledLast seenLast valueStatus + + @foreach (var s in _detail.Sources) + { + + @s.Type + @(s.IsEnabled ? "yes" : "no") + @(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") + @(s.LastValue is { } v ? Format.Number(v, 2) : "—") + @(s.LastStatus ?? "—") + + } + + + } + + +} + +@code { + [Parameter] + public int Id { get; set; } + + private MeterDetailView? _detail; + private bool _notFound; + + protected override async Task OnParametersSetAsync() + { + _detail = null; + _notFound = false; + _detail = await Details.GetAsync(Id); + _notFound = _detail is null; + } + + private static RenderFragment QualityChip(ReadingQuality quality) =>@@quality; +} diff --git a/src/App/Components/Pages/Meters.razor b/src/App/Components/Pages/Meters.razor index d8bc99c..14ef909 100644 --- a/src/App/Components/Pages/Meters.razor +++ b/src/App/Components/Pages/Meters.razor @@ -23,7 +23,7 @@ else Last seen - @context.Name + @context.Name @context.EnergyType?.DisplayName @context.Mode @context.Unit diff --git a/src/App/Components/Pages/Solar.razor b/src/App/Components/Pages/Solar.razor new file mode 100644 index 0000000..699dfab --- /dev/null +++ b/src/App/Components/Pages/Solar.razor @@ -0,0 +1,149 @@ +@page "/solar" +@rendermode InteractiveServer +@inject SolarService SolarSvc +@using MudBlazor + +MeterVault — Solar / PV + +
+ Solar / PV + + Last 12 months + Last 24 months + Last 5 years + All time + +
+ +@if (_summary is null) +{ + +} +else if (!_summary.HasGeneration) +{ + + No generation meters found. Add a meter with mode GenerationCounter, or load the reference data from + Import. + +} +else +{ + + + + Generation + @Format.Number(_summary.Generation, 0) kWh + + + + + Self-consumption + @(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—") + @if (_summary.SelfConsumptionRatio is { } ratio) + { + @Format.Number(ratio * 100, 0)% of generation + } + + + + + Autarky + @(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—") + @if (_summary.GridImport is { } grid) + { + Grid draw @Format.Number(grid, 0) kWh + } + + + + + Savings (Ersparnis) + @(_summary.Savings is { } sav ? Format.Euro(sav) : "—") + + + + + + Generation & self-consumption + + + + + + + Generation by meter + + + @foreach (var meter in _summary.Meters) + { + + @meter.Name + @Format.Number(meter.Generation, 0) kWh + + } + + + @if (!_summary.HasLoadContext) + { + + Tag a meter total_load and one grid_import (in meter metadata) + to unlock self-consumption, autarky and savings. + + } + + + +} + +@code { + private int _months = 60; + private bool _loading; + private SolarSummary? _summary; + private IReadOnlyList _chart = []; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task OnRangeChanged(int months) + { + _months = months; + await LoadAsync(); + } + + private async Task LoadAsync() + { + if (_loading) + { + return; + } + + _loading = true; + _summary = null; + try + { + var asOf = DateOnly.FromDateTime(DateTime.UtcNow); + var from = asOf.AddMonths(-_months); + _summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); + + var generation = _summary.Months + .Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation)) + .ToList(); + var series = new List + { + new("Generation", ApexCharts.SeriesType.Bar, generation), + }; + if (_summary.HasLoadContext) + { + var self = _summary.Months + .Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0)) + .ToList(); + series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self)); + } + + _chart = series; + } + finally + { + _loading = false; + } + } +} diff --git a/src/App/Components/Shared/SeriesChart.razor b/src/App/Components/Shared/SeriesChart.razor new file mode 100644 index 0000000..c323912 --- /dev/null +++ b/src/App/Components/Shared/SeriesChart.razor @@ -0,0 +1,47 @@ +@using ApexCharts + +@if (HasData) +{ + + @foreach (var series in Series) + { + + } + +} +else +{ + No data in this range. +} + +@code { + /// A single (label, value) point in a series. + public sealed record Point(string Label, double Value); + + /// A named series rendered as bars or a line over the shared category axis. + public sealed record SeriesDef(string Name, SeriesType Type, IReadOnlyList Points); + + [Parameter, EditorRequired] + public IReadOnlyList Series { get; set; } = []; + + [Parameter] + public int Height { get; set; } = 300; + + [Parameter] + public int Decimals { get; set; } = 2; + + private bool HasData => Series.Any(s => s.Points.Count > 0); + + private readonly ApexChartOptions _options = new() + { + Theme = new Theme { Mode = Mode.Dark }, + DataLabels = new DataLabels { Enabled = false }, + Legend = new Legend { Position = LegendPosition.Top }, + Stroke = new Stroke { Width = 3, Curve = Curve.Smooth }, + }; +} diff --git a/src/App/Program.cs b/src/App/Program.cs index 9b54fa1..6c5a781 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -104,6 +104,14 @@ static async Task MigrateDatabaseAsync(WebApplication app) await db.Database.MigrateAsync().ConfigureAwait(false); await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false); Log.Information("Database migrations applied and defaults seeded"); + + if (options.SeedReferenceData) + { + var importer = scope.ServiceProvider.GetRequiredService(); + var dir = Path.Combine(AppContext.BaseDirectory, "sampledata"); + await importer.LoadAsync(dir).ConfigureAwait(false); + Log.Information("Reference dataset ensured (SeedReferenceData=true)"); + } } /// Exposed for WebApplicationFactory-based integration tests. diff --git a/src/Core/Domain/MeterMeta.cs b/src/Core/Domain/MeterMeta.cs new file mode 100644 index 0000000..00b9361 --- /dev/null +++ b/src/Core/Domain/MeterMeta.cs @@ -0,0 +1,78 @@ +using System.Text.Json; + +namespace MeterVault.Core.Domain; + +/// +/// A meter's optional role in an energy system, stored under role in . +/// Roles let analytic panels (e.g. the PV self-consumption/autarky view) find the relevant meters +/// by configuration rather than by hardcoded names — nothing domain-specific is baked into code +/// (SDD §5.2, §8.4). A PV install typically tags one meter and one +/// ; self-consumption is then total_load − grid_import. +/// +public static class MeterRoles +{ + /// Meter measuring the site's total consumption (all loads). + public const string TotalLoad = "total_load"; + + /// Meter measuring energy drawn from the grid. + public const string GridImport = "grid_import"; + + /// Meter measuring energy exported to the grid. + public const string GridExport = "grid_export"; +} + +/// Typed reads over a meter's free-form Meta JSON (jsonb). Tolerant of malformed +/// or empty JSON — returns null rather than throwing, so a bad blob never breaks a dashboard. +public static class MeterMeta +{ + /// The meter's configured role (see ), or null if unset/invalid. + public static string? Role(string? meta) => ReadString(meta, "role"); + + /// Reads a top-level string property from the meta JSON; null if absent or unparsable. + public static string? ReadString(string? meta, string property) + { + if (string.IsNullOrWhiteSpace(meta)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(meta); + return doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty(property, out var value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + /// Returns with role set to . + public static string WithRole(string? meta, string role) + { + var map = ToMap(meta); + map["role"] = role; + return JsonSerializer.Serialize(map); + } + + private static Dictionary ToMap(string? meta) + { + if (string.IsNullOrWhiteSpace(meta)) + { + return new Dictionary(); + } + + try + { + return JsonSerializer.Deserialize>(meta) ?? new Dictionary(); + } + catch (JsonException) + { + return new Dictionary(); + } + } +} diff --git a/src/Infrastructure/Dashboard/ConsumableModels.cs b/src/Infrastructure/Dashboard/ConsumableModels.cs new file mode 100644 index 0000000..fd1c037 --- /dev/null +++ b/src/Infrastructure/Dashboard/ConsumableModels.cs @@ -0,0 +1,35 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Infrastructure.Dashboard; + +/// One recorded delivery into a consumable store. +public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit); + +/// One month of consumable draw. +public sealed record ConsumableMonth(DateOnly Period, double Consumption); + +/// +/// The oil / consumable panel read model (SDD §8.5) for one +/// meter: current tank level (physical + volume), fill vs capacity, deliveries, associated burner +/// runtime, effective L/h (fixed or empirical), forecast-to-empty, tariff cost and a monthly series. +/// +public sealed record ConsumableSummary( + int MeterId, + string Name, + string Unit, + double Capacity, + double? CurrentLevel, + double? PhysicalLevel, + string? PhysicalUnit, + DateTimeOffset? LevelAsOf, + double FillFraction, + double ConsumptionInRange, + double? BurnerHours, + double? EffectiveRate, + double? FixedRate, + TankRateMode RateMode, + double? AveragePerDay, + DateOnly? ForecastEmpty, + double CostInRange, + IReadOnlyList Deliveries, + IReadOnlyList Months); diff --git a/src/Infrastructure/Dashboard/ConsumableService.cs b/src/Infrastructure/Dashboard/ConsumableService.cs new file mode 100644 index 0000000..54055b5 --- /dev/null +++ b/src/Infrastructure/Dashboard/ConsumableService.cs @@ -0,0 +1,177 @@ +using Dapper; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Normalization; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Dashboard; + +/// +/// Read model for the oil / consumable panel (SDD §8.5). Works for any +/// meter backed by a — heating oil is +/// only the reference case. Current level is the latest dipstick reading (cm calibrated to volume) +/// plus deliveries recorded since; the effective burn rate pairs the consumable's litres with the +/// runtime hours of same-energy-type meters. DbContext +/// factory keeps it Blazor-circuit safe. +/// +public sealed class ConsumableService(IDbContextFactory contextFactory, CostService costService) +{ + private readonly IDbContextFactory _contextFactory = contextFactory; + private readonly CostService _costService = costService; + + public async Task> GetConsumablesAsync( + DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + var meters = await db.Meters.AsNoTracking() + .Where(m => m.Mode == MeterMode.ConsumableBalance) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var summaries = new List(); + foreach (var meter in meters) + { + var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meter.Id, cancellationToken).ConfigureAwait(false); + if (tank is null) + { + continue; + } + + summaries.Add(await BuildAsync(db, meter, tank, from, to, cancellationToken).ConfigureAwait(false)); + } + + return summaries; + } + + private async Task BuildAsync( + MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken) + { + var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration; + var fromUtc = ToUtc(from); + var toUtc = ToUtc(to); + + var events = await db.MeterEvents.AsNoTracking() + .Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery)) + .OrderBy(e => e.Time) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var lastLevel = events.LastOrDefault(e => e.EventType == MeterEventType.TankLevel); + double? currentLevel = null; + double? physicalLevel = null; + string? physicalUnit = null; + DateTimeOffset? levelAsOf = null; + if (lastLevel is not null) + { + physicalLevel = lastLevel.Amount; + physicalUnit = lastLevel.Unit; + levelAsOf = lastLevel.Time; + var volume = ToVolume(lastLevel, calibration); + // Deliveries recorded after the last dipstick raise the actual contents. + var since = events.Where(e => e.EventType == MeterEventType.Delivery && e.Time > lastLevel.Time).Sum(e => e.Amount ?? 0); + currentLevel = volume + since; + } + + var fillFraction = tank.Capacity > 0 && currentLevel is { } level + ? Math.Clamp(level / tank.Capacity, 0, 1) + : 0; + + var deliveries = events + .Where(e => e.EventType == MeterEventType.Delivery) + .OrderByDescending(e => e.Time) + .Select(e => new DeliveryRow(e.Time, e.Amount ?? 0, e.Unit)) + .ToList(); + + var consumptionInRange = await SumConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); + + // Burner runtime: same-energy-type runtime meters feed this consumable's L/h analytic. + var runtimeMeterIds = await db.Meters.AsNoTracking() + .Where(m => m.EnergyTypeId == meter.EnergyTypeId && m.Mode == MeterMode.RuntimeCounter) + .Select(m => m.Id) + .ToListAsync(cancellationToken).ConfigureAwait(false); + double? burnerHours = null; + foreach (var id in runtimeMeterIds) + { + burnerHours = (burnerHours ?? 0) + await SumConsumptionAsync(db, id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); + } + + double? effectiveRate = burnerHours is > 0 ? consumptionInRange / burnerHours : null; + double? fixedRate = tank.RateMode == TankRateMode.Fixed ? tank.FixedRate : null; + + var (averagePerDay, forecastEmpty) = await ForecastAsync(db, meter.Id, currentLevel, levelAsOf, cancellationToken).ConfigureAwait(false); + + var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false)) + .Sum(c => c.Cost); + + var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); + + return new ConsumableSummary( + meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf, + fillFraction, consumptionInRange, burnerHours, effectiveRate, fixedRate, tank.RateMode, + averagePerDay, forecastEmpty, costInRange, deliveries, months); + } + + /// Recent burn rate and a forecast-to-empty anchored at the last level reading, using + /// the trailing 365 days of consumption (delivery-only early history would otherwise skew it). + private static async Task<(double? AveragePerDay, DateOnly? ForecastEmpty)> ForecastAsync( + MeterVaultDbContext db, int meterId, double? currentLevel, DateTimeOffset? levelAsOf, CancellationToken cancellationToken) + { + var latest = await db.Consumption.AsNoTracking() + .Where(c => c.MeterId == meterId) + .OrderByDescending(c => c.Time) + .Select(c => (DateTimeOffset?)c.Time) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (latest is null || currentLevel is not { } level || level <= 0) + { + return (null, null); + } + + var windowStart = latest.Value.AddDays(-365); + var recent = await db.Consumption.AsNoTracking() + .Where(c => c.MeterId == meterId && c.Time > windowStart && c.Time <= latest.Value) + .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; + if (recent <= 0) + { + return (null, null); + } + + var averagePerDay = recent / 365.0; + var anchor = levelAsOf ?? latest.Value; + var daysToEmpty = level / averagePerDay; + // Guard against absurd horizons (near-zero burn) that overflow DateTime. + var forecast = daysToEmpty < 365 * 100 + ? DateOnly.FromDateTime(anchor.UtcDateTime.AddDays(daysToEmpty)) + : (DateOnly?)null; + return (averagePerDay, forecast); + } + + private static async Task SumConsumptionAsync( + MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) => + await db.Consumption.AsNoTracking() + .Where(c => c.MeterId == meterId && c.Time >= from && c.Time < to) + .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; + + private static async Task> MonthlyConsumptionAsync( + MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) + { + const string sql = + "SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " + + "sum(amount) AS amount " + + "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + + "GROUP BY period ORDER BY period"; + + var connection = db.Database.GetDbConnection(); + var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken); + var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false); + return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList(); + } + + private static double ToVolume(MeterEvent level, MeterVault.Core.Normalization.CalibrationCurve? calibration) + { + var value = level.Amount ?? 0; + var isCentimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase); + return isCentimetres && calibration is not null ? calibration.ToVolume(value) : value; + } + + private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero); +} diff --git a/src/Infrastructure/Dashboard/MeterDetailModels.cs b/src/Infrastructure/Dashboard/MeterDetailModels.cs new file mode 100644 index 0000000..c86f38b --- /dev/null +++ b/src/Infrastructure/Dashboard/MeterDetailModels.cs @@ -0,0 +1,49 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Infrastructure.Dashboard; + +/// A raw reading row for the meter-detail table. +public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQuality Quality, ReadingFlags Flags); + +/// A normalized consumption row for the meter-detail table. +public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality); + +/// A meter lifecycle/correction event row. +public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes); + +/// A tariff applicable to the meter (own / energy-type / global scope), for the timeline. +public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo); + +/// Source binding + live status (last-seen / last value / last status). +public sealed record SourceRow(SourceType Type, bool IsEnabled, DateTimeOffset? LastSeenAt, double? LastValue, string? LastStatus, string Config); + +/// +/// The meter-detail read model (SDD §8.6): identity, register span, totals, recent raw readings +/// and normalized consumption (measured-vs-estimated markers via quality), source status, the +/// applicable tariff timeline, and lifecycle events (swaps/deliveries/corrections). +/// +public sealed record MeterDetailView( + int Id, + string Name, + string EnergyType, + MeterMode Mode, + string Unit, + string? Location, + string? SerialNumber, + string? Manufacturer, + string? Model, + double InitialBaseline, + bool IsActive, + int ReadingCount, + int ConsumptionCount, + DateTimeOffset? FirstReadingTime, + DateTimeOffset? LastReadingTime, + double? FirstReadingValue, + double? LastReadingValue, + double TotalConsumption, + double TotalGeneration, + IReadOnlyList RecentReadings, + IReadOnlyList RecentConsumption, + IReadOnlyList Events, + IReadOnlyList Tariffs, + IReadOnlyList Sources); diff --git a/src/Infrastructure/Dashboard/MeterDetailService.cs b/src/Infrastructure/Dashboard/MeterDetailService.cs new file mode 100644 index 0000000..faffbc1 --- /dev/null +++ b/src/Infrastructure/Dashboard/MeterDetailService.cs @@ -0,0 +1,88 @@ +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Dashboard; + +/// +/// Read model for the meter-detail view (SDD §8.6). Bounds the raw-reading and consumption pulls +/// (this is the one place the UI touches raw rows) and gathers source status, the applicable tariff +/// timeline and lifecycle events. DbContext factory keeps it Blazor-circuit safe. +/// +public sealed class MeterDetailService(IDbContextFactory contextFactory) +{ + private const int MaxRows = 200; + + private readonly IDbContextFactory _contextFactory = contextFactory; + + public async Task GetAsync(int meterId, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + var meter = await db.Meters.AsNoTracking() + .Include(m => m.EnergyType) + .Include(m => m.Sources) + .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); + if (meter is null) + { + return null; + } + + var readingCount = await db.Readings.AsNoTracking().CountAsync(r => r.MeterId == meterId, cancellationToken).ConfigureAwait(false); + var consumptionCount = await db.Consumption.AsNoTracking().CountAsync(c => c.MeterId == meterId, cancellationToken).ConfigureAwait(false); + + var first = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId) + .OrderBy(r => r.Time).Select(r => new { r.Time, r.Value }) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + var last = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId) + .OrderByDescending(r => r.Time).Select(r => new { r.Time, r.Value }) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + var totalConsumption = await db.Consumption.AsNoTracking() + .Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Consumption) + .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; + var totalGeneration = await db.Consumption.AsNoTracking() + .Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Generation) + .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; + + var recentReadings = await db.Readings.AsNoTracking() + .Where(r => r.MeterId == meterId) + .OrderByDescending(r => r.Time).Take(MaxRows) + .Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var recentConsumption = await db.Consumption.AsNoTracking() + .Where(c => c.MeterId == meterId) + .OrderByDescending(c => c.Time).Take(MaxRows) + .Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var events = await db.MeterEvents.AsNoTracking() + .Where(e => e.MeterId == meterId) + .OrderByDescending(e => e.Time) + .Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var energyTypeId = meter.EnergyTypeId; + var tariffs = await db.Tariffs.AsNoTracking() + .Where(t => t.ScopeType == TariffScope.Global + || (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId) + || (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId)) + .OrderBy(t => t.Component).ThenBy(t => t.ValidFrom) + .Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var sources = meter.Sources + .OrderBy(s => s.Priority) + .Select(s => new SourceRow(s.SourceType, s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus, s.Config)) + .ToList(); + + return new MeterDetailView( + meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit, + meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive, + readingCount, consumptionCount, + first?.Time, last?.Time, first?.Value, last?.Value, + totalConsumption, totalGeneration, + recentReadings, recentConsumption, events, tariffs, sources); + } +} diff --git a/src/Infrastructure/Dashboard/SolarModels.cs b/src/Infrastructure/Dashboard/SolarModels.cs new file mode 100644 index 0000000..5a4b1eb --- /dev/null +++ b/src/Infrastructure/Dashboard/SolarModels.cs @@ -0,0 +1,37 @@ +namespace MeterVault.Infrastructure.Dashboard; + +/// One month of the PV panel: generation, and (when role-tagged meters exist) the +/// self-consumption / grid-draw / savings split that reproduces the sheet's Netz-Einsparung column. +public sealed record SolarMonth( + DateOnly Period, + double Generation, + double? SelfConsumption, + double? GridImport, + double? TotalLoad, + double? Savings); + +/// Per-generation-meter total over the selected period (for the ranked list). +public sealed record GenerationMeterRow(int MeterId, string Name, double Generation); + +/// +/// The PV / solar panel read model (SDD §8.4): total generation plus, when the install has tagged +/// a total_load and grid_import meter, self-consumption, autarky %, self-consumption % +/// and savings (Ersparnis). Derived metrics are null when no role config exists. +/// +public sealed record SolarSummary( + double Generation, + double? TotalLoad, + double? GridImport, + double? SelfConsumption, + double? Autarky, + double? SelfConsumptionRatio, + double? Savings, + IReadOnlyList Meters, + IReadOnlyList Months) +{ + /// True when the install has the role-tagged meters needed for self-consumption metrics. + public bool HasLoadContext => TotalLoad is not null && GridImport is not null; + + /// True when at least one generation meter exists. + public bool HasGeneration => Meters.Count > 0; +} diff --git a/src/Infrastructure/Dashboard/SolarService.cs b/src/Infrastructure/Dashboard/SolarService.cs new file mode 100644 index 0000000..6a284c3 --- /dev/null +++ b/src/Infrastructure/Dashboard/SolarService.cs @@ -0,0 +1,116 @@ +using Dapper; +using MeterVault.Core.Costing; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Dashboard; + +/// +/// Read model for the PV / solar panel (SDD §8.4). Generation comes from every +/// meter; self-consumption / autarky / savings are derived +/// from the meters tagged and +/// — so nothing is hardcoded by meter name. Reads only the aggregated consumption hypertable +/// (monthly, Europe/Berlin) via Dapper; safe from a Blazor circuit via a DbContext factory. +/// +public sealed class SolarService(IDbContextFactory contextFactory) +{ + private readonly IDbContextFactory _contextFactory = contextFactory; + + public async Task GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + var meters = await db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + var generationMeters = meters.Where(m => m.Mode == MeterMode.GenerationCounter).ToList(); + var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad); + var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport); + + var fromUtc = ToUtc(from); + var toUtc = ToUtc(to); + + // Monthly generation per generation meter. + var genByMeter = new Dictionary>(); + foreach (var meter in generationMeters) + { + genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); + } + + var loadByMonth = loadMeter is null + ? null + : await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); + var gridByMonth = gridMeter is null + ? null + : await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); + + var tariffs = gridMeter is null + ? [] + : await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + + // Union of all months that carry any data. + var periods = new SortedSet(); + foreach (var series in genByMeter.Values) + { + periods.UnionWith(series.Keys); + } + + if (loadByMonth is not null) + { + periods.UnionWith(loadByMonth.Keys); + } + + var months = new List(); + foreach (var period in periods) + { + var generation = genByMeter.Values.Sum(s => s.GetValueOrDefault(period)); + + double? load = loadByMonth?.GetValueOrDefault(period); + double? grid = gridByMonth?.GetValueOrDefault(period); + double? self = load is not null && grid is not null ? load - grid : null; + + double? savings = null; + if (self is { } selfValue && gridMeter is not null) + { + var price = TariffResolver.ResolveValue( + tariffs, TariffComponent.UnitPrice, gridMeter.Id, gridMeter.EnergyTypeId, + new DateOnly(period.Year, period.Month, 15)); + savings = selfValue * price; + } + + months.Add(new SolarMonth(period, generation, self, grid, load, savings)); + } + + var meterRows = generationMeters + .Select(m => new GenerationMeterRow(m.Id, m.Name, genByMeter[m.Id].Values.Sum())) + .OrderByDescending(r => r.Generation) + .ToList(); + + var totalGeneration = meterRows.Sum(r => r.Generation); + double? totalLoad = loadByMonth?.Values.Sum(); + double? totalGrid = gridByMonth?.Values.Sum(); + double? totalSelf = totalLoad is not null && totalGrid is not null ? totalLoad - totalGrid : null; + double? autarky = totalSelf is not null && totalLoad is > 0 ? totalSelf / totalLoad : null; + double? selfRatio = totalSelf is not null && totalGeneration > 0 ? totalSelf / totalGeneration : null; + double? totalSavings = months.Any(m => m.Savings is not null) ? months.Sum(m => m.Savings ?? 0) : null; + + return new SolarSummary( + totalGeneration, totalLoad, totalGrid, totalSelf, autarky, selfRatio, totalSavings, meterRows, months); + } + + private static async Task> MonthlyAsync( + MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) + { + const string sql = + "SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " + + "sum(amount) AS amount " + + "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + + "GROUP BY period"; + + var connection = db.Database.GetDbConnection(); + var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken); + var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false); + return rows.ToDictionary(r => r.Period, r => r.Amount); + } + + private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero); +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 7d97f7c..eae30de 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -35,6 +35,9 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/src/Infrastructure/Import/ReferenceDataImporter.cs b/src/Infrastructure/Import/ReferenceDataImporter.cs index 988f109..e0d0557 100644 --- a/src/Infrastructure/Import/ReferenceDataImporter.cs +++ b/src/Infrastructure/Import/ReferenceDataImporter.cs @@ -33,6 +33,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService return; } + // Fail fast BEFORE creating the marker meter: if the CSVs are missing (e.g. not shipped in + // the image) we must not seed a half-loaded dataset that IsLoadedAsync then reports as done. + EnsureSampleFilesPresent(sampleDataDirectory); + await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false); var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false); @@ -48,6 +52,11 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L"); var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h"); + // Tag the PV meters' roles (config, not hardcoded names) so the Solar panel can derive + // self-consumption = total_load − grid_import and savings generically (SDD §8.4). + haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad); + netz.Meta = MeterMeta.WithRole(netz.Meta, MeterRoles.GridImport); + _db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); @@ -86,12 +95,32 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }], }; + private static readonly string[] RequiredFiles = [ElectricityFile, WaterFile, OilFile, CostsFile]; + + /// Throws a clear error if the sample directory or any reference CSV is missing, so a + /// failed load surfaces to the user instead of silently seeding meters with no data. + private static void EnsureSampleFilesPresent(string sampleDataDirectory) + { + if (!Directory.Exists(sampleDataDirectory)) + { + throw new DirectoryNotFoundException( + $"Reference-data directory not found: '{sampleDataDirectory}'. The bundled Energiebilanz CSVs are missing from this deployment."); + } + + var missing = RequiredFiles.Where(f => !File.Exists(Path.Combine(sampleDataDirectory, f))).ToList(); + if (missing.Count > 0) + { + throw new FileNotFoundException( + $"Reference CSV(s) missing from '{sampleDataDirectory}': {string.Join(", ", missing)}."); + } + } + private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken) { var path = Path.Combine(dir, file); if (!File.Exists(path)) { - return; + throw new FileNotFoundException($"Reference CSV disappeared during import: '{path}'.", path); } StagedImport staged; diff --git a/src/Infrastructure/Options/MeterVaultOptions.cs b/src/Infrastructure/Options/MeterVaultOptions.cs index a828095..38b1417 100644 --- a/src/Infrastructure/Options/MeterVaultOptions.cs +++ b/src/Infrastructure/Options/MeterVaultOptions.cs @@ -19,6 +19,13 @@ public sealed class MeterVaultOptions /// Run EF migrations on startup. Disable for tests that migrate out-of-band. public bool RunMigrationsAtStartup { get; set; } = true; + /// + /// Load the bundled Energiebilanz reference dataset on startup if the database has none yet + /// (idempotent — guarded by a marker meter). Off by default; set MeterVault__SeedReferenceData=true + /// for a one-command populated demo/test instance. + /// + public bool SeedReferenceData { get; set; } + /// Start the MQTT/Home Assistant ingestion workers. Disable for tests. public bool EnableLiveIngestion { get; set; } = true; diff --git a/tests/Core.Tests/MeterMetaTests.cs b/tests/Core.Tests/MeterMetaTests.cs new file mode 100644 index 0000000..c23257f --- /dev/null +++ b/tests/Core.Tests/MeterMetaTests.cs @@ -0,0 +1,49 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Tests; + +public sealed class MeterMetaTests +{ + [Fact] + public void Role_reads_configured_role() + { + Assert.Equal(MeterRoles.GridImport, MeterMeta.Role("{\"role\":\"grid_import\"}")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + [InlineData("not json")] + [InlineData("{\"role\":123}")] + [InlineData("[1,2,3]")] + public void Role_is_null_when_absent_or_malformed(string meta) + { + Assert.Null(MeterMeta.Role(meta)); + } + + [Fact] + public void WithRole_sets_role_and_preserves_other_keys() + { + var updated = MeterMeta.WithRole("{\"expression\":\"a-b\"}", MeterRoles.TotalLoad); + + Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(updated)); + Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression")); + } + + [Fact] + public void WithRole_overwrites_existing_role() + { + var updated = MeterMeta.WithRole("{\"role\":\"grid_import\"}", MeterRoles.GridExport); + + Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(updated)); + } + + [Fact] + public void WithRole_handles_empty_meta() + { + var updated = MeterMeta.WithRole("", MeterRoles.GridImport); + + Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(updated)); + } +} diff --git a/tests/Integration.Tests/DashboardRenderTests.cs b/tests/Integration.Tests/DashboardRenderTests.cs index bc93f3f..8cd5258 100644 --- a/tests/Integration.Tests/DashboardRenderTests.cs +++ b/tests/Integration.Tests/DashboardRenderTests.cs @@ -1,4 +1,5 @@ using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Dashboard; using MeterVault.Infrastructure.Import; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -43,6 +44,36 @@ public sealed class DashboardRenderTests(TimescaleFixture fx) Assert.Equal(70d, rollup.Sum(r => r.Cost), 1); } + // Panel read models compute real figures from the reference data (SDD §8.4–§8.6). + int hausId; + using (var scope = factory.Services.CreateScope()) + { + var services = scope.ServiceProvider; + var wide = new DateOnly(1997, 1, 1); + var toEnd = new DateOnly(2027, 1, 1); + + var solar = await services.GetRequiredService().GetSummaryAsync(wide, toEnd); + Assert.True(solar.HasGeneration); + Assert.True(solar.Generation > 0); + // Haus (total_load) + Netz (grid_import) are role-tagged, so self-consumption/savings resolve. + Assert.True(solar.HasLoadContext); + Assert.NotNull(solar.SelfConsumption); + Assert.NotNull(solar.Savings); + + var consumables = await services.GetRequiredService().GetConsumablesAsync(wide, toEnd); + var oil = Assert.Single(consumables); + Assert.True(oil.CurrentLevel is > 0); + Assert.NotEmpty(oil.Deliveries); + Assert.True(oil.ConsumptionInRange > 0); + + await using var db = fx.CreateContext(); + hausId = await db.Meters.Where(m => m.Name == "Zähler Haus").Select(m => m.Id).FirstAsync(); + var detail = await services.GetRequiredService().GetAsync(hausId); + Assert.NotNull(detail); + Assert.True(detail!.ReadingCount > 0); + Assert.True(detail.TotalConsumption > 0); + } + using var client = factory.CreateClient(); var overview = await client.GetAsync(new Uri("/", UriKind.Relative)); @@ -55,7 +86,11 @@ public sealed class DashboardRenderTests(TimescaleFixture fx) Assert.Contains("This year", html, StringComparison.Ordinal); Assert.Contains("Latest month with data", html, StringComparison.Ordinal); - foreach (var path in new[] { "/meters", "/trends", "/import", "/admin/tariffs", "/admin/energy-types" }) + foreach (var path in new[] + { + "/meters", "/trends", "/solar", "/consumables", "/import", + "/admin/tariffs", "/admin/energy-types", $"/meters/{hausId}", + }) { var response = await client.GetAsync(new Uri(path, UriKind.Relative)); response.EnsureSuccessStatusCode();