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
@@ -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;