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:
@@ -5,7 +5,6 @@
|
|||||||
.git/
|
.git/
|
||||||
.github/
|
.github/
|
||||||
docs/
|
docs/
|
||||||
sampledata/
|
|
||||||
tests/
|
tests/
|
||||||
**/*.user
|
**/*.user
|
||||||
**/appsettings.*.Local.json
|
**/appsettings.*.Local.json
|
||||||
|
|||||||
@@ -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**.
|
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
|
## Source of truth
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ full design.
|
|||||||
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
||||||
- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw.
|
- **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/
|
- **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).
|
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
|
||||||
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ full design.
|
|||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml up -d
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
# open http://localhost:8080 → Import → "Load reference data" for a populated demo
|
# 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
|
# 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__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__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
||||||
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
| `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
|
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).
|
returns 401. Set at least one API key (or open it explicitly for a trusted network).
|
||||||
|
|||||||
+3
-1
@@ -11,8 +11,10 @@ COPY src/Infrastructure/MeterVault.Infrastructure.csproj src/Infrastructure/
|
|||||||
COPY src/App/MeterVault.App.csproj src/App/
|
COPY src/App/MeterVault.App.csproj src/App/
|
||||||
RUN dotnet restore src/App/MeterVault.App.csproj
|
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 src/ ./src/
|
||||||
|
COPY sampledata/ ./sampledata/
|
||||||
RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false
|
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
|
# Debian-based runtime (keeps full ICU — required by the de-DE CSV importer; do not use
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ services:
|
|||||||
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
||||||
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
||||||
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
|
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):
|
# 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__ApiKeys__0: your-secret-key
|
||||||
# MeterVault__AllowAnonymousApi: "true"
|
# MeterVault__AllowAnonymousApi: "true"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
<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="/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="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
||||||
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
||||||
<MudDivider Class="my-2" />
|
<MudDivider Class="my-2" />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ else
|
|||||||
<MudTh>Last seen</MudTh>
|
<MudTh>Last seen</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<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="Type">@context.EnergyType?.DisplayName</MudTd>
|
||||||
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
||||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||||
|
|||||||
@@ -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 & 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -104,6 +104,14 @@ static async Task MigrateDatabaseAsync(WebApplication app)
|
|||||||
await db.Database.MigrateAsync().ConfigureAwait(false);
|
await db.Database.MigrateAsync().ConfigureAwait(false);
|
||||||
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
||||||
Log.Information("Database migrations applied and defaults seeded");
|
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>
|
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
||||||
|
|||||||
@@ -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<MqttMessageRouter>();
|
||||||
services.AddScoped<Costing.CostService>();
|
services.AddScoped<Costing.CostService>();
|
||||||
services.AddScoped<Dashboard.DashboardService>();
|
services.AddScoped<Dashboard.DashboardService>();
|
||||||
|
services.AddScoped<Dashboard.SolarService>();
|
||||||
|
services.AddScoped<Dashboard.ConsumableService>();
|
||||||
|
services.AddScoped<Dashboard.MeterDetailService>();
|
||||||
services.AddScoped<Backup.ExportService>();
|
services.AddScoped<Backup.ExportService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
|||||||
return;
|
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);
|
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var electricity = await EnergyTypeIdAsync("electricity", 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 oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
|
||||||
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
|
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);
|
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner);
|
||||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
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 }],
|
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)
|
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = Path.Combine(dir, file);
|
var path = Path.Combine(dir, file);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
return;
|
throw new FileNotFoundException($"Reference CSV disappeared during import: '{path}'.", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
StagedImport staged;
|
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>
|
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
|
||||||
public bool RunMigrationsAtStartup { get; set; } = true;
|
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>
|
/// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary>
|
||||||
public bool EnableLiveIngestion { get; set; } = true;
|
public bool EnableLiveIngestion { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -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.Costing;
|
||||||
|
using MeterVault.Infrastructure.Dashboard;
|
||||||
using MeterVault.Infrastructure.Import;
|
using MeterVault.Infrastructure.Import;
|
||||||
using MeterVault.Infrastructure.Persistence;
|
using MeterVault.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -43,6 +44,36 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
|||||||
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
|
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();
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
var overview = await client.GetAsync(new Uri("/", UriKind.Relative));
|
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("This year", html, StringComparison.Ordinal);
|
||||||
Assert.Contains("Latest month with data", 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));
|
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|||||||
Reference in New Issue
Block a user