SDD §8 panels (PV/oil/meter-detail) + fix reference-data-in-Docker

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
This commit is contained in:
2026-07-14 09:52:11 +02:00
parent 39da00d486
commit 1282acf82c
24 changed files with 1327 additions and 7 deletions
-1
View File
@@ -5,7 +5,6 @@
.git/
.github/
docs/
sampledata/
tests/
**/*.user
**/appsettings.*.Local.json
+1 -1
View File
@@ -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 (M0M7).** 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 (M0M7) + 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
+6 -1
View File
@@ -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).
+3 -1
View File
@@ -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
+3
View File
@@ -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"
+2
View File
@@ -1,6 +1,8 @@
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
<MudDivider Class="my-2" />
+186
View File
@@ -0,0 +1,186 @@
@page "/consumables"
@rendermode InteractiveServer
@inject ConsumableService ConsumablesSvc
@using MudBlazor
<PageTitle>MeterVault — Consumables</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Oil / consumables</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
</MudSelect>
</div>
@if (_items is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_items.Count == 0)
{
<MudAlert Severity="Severity.Info">
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
data from <MudLink Href="/import">Import</MudLink>.
</MudAlert>
}
else
{
@foreach (var item in _items)
{
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
<MudGrid>
<MudItem xs="12" md="4">
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
<MudText Typo="Typo.h5">
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
</MudText>
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
{
<text> · @Format.Number(cm, 0) cm</text>
}
@if (item.LevelAsOf is { } asOf)
{
<text> · as of @asOf.ToString("yyyy-MM-dd")</text>
}
</MudText>
</MudItem>
<MudItem xs="12" md="8">
<MudGrid>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText>
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText>
<MudText Typo="Typo.subtitle1">
@if (item.FixedRate is { } fr)
{
<text>@Format.Number(fr, 2) @item.Unit/h</text>
}
else if (item.EffectiveRate is { } er)
{
<text>@Format.Number(er, 2) @item.Unit/h</text>
}
else
{
<text>—</text>
}
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText>
<MudText Typo="Typo.subtitle1">
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
@if (item.AveragePerDay is { } apd)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
(@Format.Number(apd, 1) @item.Unit/day)
</MudText>
}
</MudText>
</MudItem>
</MudGrid>
</MudItem>
<MudItem xs="12" md="7">
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
</MudItem>
<MudItem xs="12" md="5">
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText>
@if (item.Deliveries.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText>
}
else
{
<div style="max-height:260px; overflow-y:auto">
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr><th>Date</th><th style="text-align:right">Amount</th></tr>
</thead>
<tbody>
@foreach (var delivery in item.Deliveries)
{
<tr>
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
}
</MudItem>
</MudGrid>
</MudPaper>
}
}
@code {
private int _months = 60;
private bool _loading;
private IReadOnlyList<ConsumableSummary>? _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<SeriesChart.SeriesDef> 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,
};
}
+216
View File
@@ -0,0 +1,216 @@
@page "/meters/{Id:int}"
@rendermode InteractiveServer
@inject MeterDetailService Details
@inject NavigationManager Nav
@using MudBlazor
<PageTitle>MeterVault — Meter</PageTitle>
@if (_detail is null)
{
@if (_notFound)
{
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
}
else
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
}
else
{
<div class="d-flex align-center mb-4" style="gap:.75rem">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
<MudText Typo="Typo.h4">@_detail.Name</MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
@if (!_detail.IsActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
}
</div>
<MudGrid Class="mb-2">
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText>
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
</MudPaper>
</MudItem>
@if (_detail.TotalGeneration != 0)
{
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalGeneration, 0) @_detail.Unit</MudText>
</MudPaper>
</MudItem>
}
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
<MudText Typo="Typo.h6">
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
@if (_detail.RecentReadings.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
<tbody>
@foreach (var r in _detail.RecentReadings)
{
<tr>
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td>
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
<td>@QualityChip(r.Quality)</td>
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
@if (_detail.RecentConsumption.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
<tbody>
@foreach (var c in _detail.RecentConsumption)
{
<tr>
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td>
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
<td>@c.Kind</td>
<td>@QualityChip(c.Quality)</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
@if (_detail.Events.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
<tbody>
@foreach (var e in _detail.Events)
{
<tr>
<td>@e.Time.ToString("yyyy-MM-dd")</td>
<td>@e.Type</td>
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
<td>@e.Notes</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
@if (_detail.Tariffs.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
<tbody>
@foreach (var t in _detail.Tariffs)
{
<tr>
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
<td>@t.Component</td>
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
<td>@t.Unit</td>
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Sources ({_detail.Sources.Count})")">
@if (_detail.Sources.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Type</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th></tr></thead>
<tbody>
@foreach (var s in _detail.Sources)
{
<tr>
<td>@s.Type</td>
<td>@(s.IsEnabled ? "yes" : "no")</td>
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
<td>@(s.LastStatus ?? "—")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
</MudTabs>
}
@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) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality</MudChip>;
}
+1 -1
View File
@@ -23,7 +23,7 @@ else
<MudTh>Last seen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
<MudTd DataLabel="Mode">@context.Mode</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd>
+149
View File
@@ -0,0 +1,149 @@
@page "/solar"
@rendermode InteractiveServer
@inject SolarService SolarSvc
@using MudBlazor
<PageTitle>MeterVault — Solar / PV</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Solar / PV</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
</MudSelect>
</div>
@if (_summary is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (!_summary.HasGeneration)
{
<MudAlert Severity="Severity.Info">
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from
<MudLink Href="/import">Import</MudLink>.
</MudAlert>
}
else
{
<MudGrid>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Self-consumption</MudText>
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
@if (_summary.SelfConsumptionRatio is { } ratio)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">@Format.Number(ratio * 100, 0)% of generation</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Autarky</MudText>
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
@if (_summary.GridImport is { } grid)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid draw @Format.Number(grid, 0) kWh</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Savings (Ersparnis)</MudText>
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Generation &amp; self-consumption</MudText>
<SeriesChart Series="_chart" Decimals="0" Height="340" />
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Generation by meter</MudText>
<MudSimpleTable Dense="true" Hover="true">
<tbody>
@foreach (var meter in _summary.Meters)
{
<tr>
<td>@meter.Name</td>
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
</tr>
}
</tbody>
</MudSimpleTable>
@if (!_summary.HasLoadContext)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
Tag a meter <code>total_load</code> and one <code>grid_import</code> (in meter metadata)
to unlock self-consumption, autarky and savings.
</MudAlert>
}
</MudPaper>
</MudItem>
</MudGrid>
}
@code {
private int _months = 60;
private bool _loading;
private SolarSummary? _summary;
private IReadOnlyList<SeriesChart.SeriesDef> _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<SeriesChart.SeriesDef>
{
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;
}
}
}
@@ -0,0 +1,47 @@
@using ApexCharts
@if (HasData)
{
<ApexChart TItem="SeriesChart.Point" Options="_options" Height="@Height">
@foreach (var series in Series)
{
<ApexPointSeries TItem="SeriesChart.Point"
Items="series.Points"
SeriesType="series.Type"
Name="@series.Name"
XValue="p => p.Label"
YValue="p => (decimal)Math.Round(p.Value, Decimals)" />
}
</ApexChart>
}
else
{
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
}
@code {
/// <summary>A single (label, value) point in a series.</summary>
public sealed record Point(string Label, double Value);
/// <summary>A named series rendered as bars or a line over the shared category axis.</summary>
public sealed record SeriesDef(string Name, SeriesType Type, IReadOnlyList<Point> Points);
[Parameter, EditorRequired]
public IReadOnlyList<SeriesDef> 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<SeriesChart.Point> _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 },
};
}
+8
View File
@@ -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<MeterVault.Infrastructure.Import.ReferenceDataImporter>();
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
await importer.LoadAsync(dir).ConfigureAwait(false);
Log.Information("Reference dataset ensured (SeedReferenceData=true)");
}
}
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
+78
View File
@@ -0,0 +1,78 @@
using System.Text.Json;
namespace MeterVault.Core.Domain;
/// <summary>
/// A meter's optional role in an energy system, stored under <c>role</c> in <see cref="Meter.Meta"/>.
/// 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 <see cref="TotalLoad"/> and one
/// <see cref="GridImport"/>; self-consumption is then <c>total_load grid_import</c>.
/// </summary>
public static class MeterRoles
{
/// <summary>Meter measuring the site's total consumption (all loads).</summary>
public const string TotalLoad = "total_load";
/// <summary>Meter measuring energy drawn from the grid.</summary>
public const string GridImport = "grid_import";
/// <summary>Meter measuring energy exported to the grid.</summary>
public const string GridExport = "grid_export";
}
/// <summary>Typed reads over a meter's free-form <c>Meta</c> JSON (jsonb). Tolerant of malformed
/// or empty JSON — returns null rather than throwing, so a bad blob never breaks a dashboard.</summary>
public static class MeterMeta
{
/// <summary>The meter's configured <c>role</c> (see <see cref="MeterRoles"/>), or null if unset/invalid.</summary>
public static string? Role(string? meta) => ReadString(meta, "role");
/// <summary>Reads a top-level string property from the meta JSON; null if absent or unparsable.</summary>
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;
}
}
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set to <paramref name="role"/>.</summary>
public static string WithRole(string? meta, string role)
{
var map = ToMap(meta);
map["role"] = role;
return JsonSerializer.Serialize(map);
}
private static Dictionary<string, object?> ToMap(string? meta)
{
if (string.IsNullOrWhiteSpace(meta))
{
return new Dictionary<string, object?>();
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(meta) ?? new Dictionary<string, object?>();
}
catch (JsonException)
{
return new Dictionary<string, object?>();
}
}
}
@@ -0,0 +1,35 @@
using MeterVault.Core.Domain;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>One recorded delivery into a consumable store.</summary>
public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit);
/// <summary>One month of consumable draw.</summary>
public sealed record ConsumableMonth(DateOnly Period, double Consumption);
/// <summary>
/// The oil / consumable panel read model (SDD §8.5) for one <see cref="MeterMode.ConsumableBalance"/>
/// 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.
/// </summary>
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<DeliveryRow> Deliveries,
IReadOnlyList<ConsumableMonth> Months);
@@ -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;
/// <summary>
/// Read model for the oil / consumable panel (SDD §8.5). Works for any
/// <see cref="MeterMode.ConsumableBalance"/> meter backed by a <see cref="Tank"/> — 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 <see cref="MeterMode.RuntimeCounter"/> meters. DbContext
/// factory keeps it Blazor-circuit safe.
/// </summary>
public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
{
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
private readonly CostService _costService = costService;
public async Task<IReadOnlyList<ConsumableSummary>> 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<ConsumableSummary>();
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<ConsumableSummary> 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);
}
/// <summary>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).</summary>
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<double> 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<IReadOnlyList<ConsumableMonth>> 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);
}
@@ -0,0 +1,49 @@
using MeterVault.Core.Domain;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>A raw reading row for the meter-detail table.</summary>
public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQuality Quality, ReadingFlags Flags);
/// <summary>A normalized consumption row for the meter-detail table.</summary>
public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality);
/// <summary>A meter lifecycle/correction event row.</summary>
public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
/// <summary>A tariff applicable to the meter (own / energy-type / global scope), for the timeline.</summary>
public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
/// <summary>Source binding + live status (last-seen / last value / last status).</summary>
public sealed record SourceRow(SourceType Type, bool IsEnabled, DateTimeOffset? LastSeenAt, double? LastValue, string? LastStatus, string Config);
/// <summary>
/// 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).
/// </summary>
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<ReadingRow> RecentReadings,
IReadOnlyList<ConsumptionDetailRow> RecentConsumption,
IReadOnlyList<EventRow> Events,
IReadOnlyList<TariffRow> Tariffs,
IReadOnlyList<SourceRow> Sources);
@@ -0,0 +1,88 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>
/// 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.
/// </summary>
public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> contextFactory)
{
private const int MaxRows = 200;
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
public async Task<MeterDetailView?> 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);
}
}
@@ -0,0 +1,37 @@
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>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.</summary>
public sealed record SolarMonth(
DateOnly Period,
double Generation,
double? SelfConsumption,
double? GridImport,
double? TotalLoad,
double? Savings);
/// <summary>Per-generation-meter total over the selected period (for the ranked list).</summary>
public sealed record GenerationMeterRow(int MeterId, string Name, double Generation);
/// <summary>
/// The PV / solar panel read model (SDD §8.4): total generation plus, when the install has tagged
/// a <c>total_load</c> and <c>grid_import</c> meter, self-consumption, autarky %, self-consumption %
/// and savings (Ersparnis). Derived metrics are null when no role config exists.
/// </summary>
public sealed record SolarSummary(
double Generation,
double? TotalLoad,
double? GridImport,
double? SelfConsumption,
double? Autarky,
double? SelfConsumptionRatio,
double? Savings,
IReadOnlyList<GenerationMeterRow> Meters,
IReadOnlyList<SolarMonth> Months)
{
/// <summary>True when the install has the role-tagged meters needed for self-consumption metrics.</summary>
public bool HasLoadContext => TotalLoad is not null && GridImport is not null;
/// <summary>True when at least one generation meter exists.</summary>
public bool HasGeneration => Meters.Count > 0;
}
@@ -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;
/// <summary>
/// Read model for the PV / solar panel (SDD §8.4). Generation comes from every
/// <see cref="MeterMode.GenerationCounter"/> meter; self-consumption / autarky / savings are derived
/// from the meters tagged <see cref="MeterRoles.TotalLoad"/> and <see cref="MeterRoles.GridImport"/>
/// — 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.
/// </summary>
public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextFactory)
{
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
public async Task<SolarSummary> 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<int, IReadOnlyDictionary<DateOnly, double>>();
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<DateOnly>();
foreach (var series in genByMeter.Values)
{
periods.UnionWith(series.Keys);
}
if (loadByMonth is not null)
{
periods.UnionWith(loadByMonth.Keys);
}
var months = new List<SolarMonth>();
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<IReadOnlyDictionary<DateOnly, double>> 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);
}
@@ -35,6 +35,9 @@ public static class DependencyInjection
services.AddScoped<MqttMessageRouter>();
services.AddScoped<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>();
services.AddScoped<Dashboard.SolarService>();
services.AddScoped<Dashboard.ConsumableService>();
services.AddScoped<Dashboard.MeterDetailService>();
services.AddScoped<Backup.ExportService>();
return services;
@@ -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];
/// <summary>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.</summary>
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;
@@ -19,6 +19,13 @@ public sealed class MeterVaultOptions
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
public bool RunMigrationsAtStartup { get; set; } = true;
/// <summary>
/// Load the bundled Energiebilanz reference dataset on startup if the database has none yet
/// (idempotent — guarded by a marker meter). Off by default; set <c>MeterVault__SeedReferenceData=true</c>
/// for a one-command populated demo/test instance.
/// </summary>
public bool SeedReferenceData { get; set; }
/// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary>
public bool EnableLiveIngestion { get; set; } = true;
+49
View File
@@ -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));
}
}
@@ -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<SolarService>().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<ConsumableService>().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<MeterDetailService>().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();