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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user