Correctness/data: - Fix demo cost double-count: reference importer no longer imports the Kosten Strom/Wasser columns for categories that are metered (only Heizung), so Wasser rollup is 70€ not 140€. - Spurious-decrease guard: only a reset/swap in the window (prevReading, thisReading] explains a decrease — an old historical reset no longer permanently disables the guard. - Gate swap auto-detection on MappingProfile.DetectCumulativeSwaps (flag was ignored). - Prorate basePrice by bucket length (day/month/year); guard virtual expressions against NaN/Inf. Concurrency/infra: - Blazor: register a DbContextFactory; CostService/DashboardService and the read pages now use short-lived per-operation contexts (no shared circuit DbContext); guard Trends re-entrancy. - /events: wrap event insert + consumption recompute in one transaction (atomic); 404 (not 500) on unknown meter. - MQTT worker: subscribe to newly-added topics on each tick; move client cleanup into finally. - Migrations: CREATE MATERIALIZED VIEW IF NOT EXISTS + if_not_exists on CAgg/compression/ hypertable calls (re-run-safe after a mid-migration crash). - HA worker: prune stale poll-schedule entries; export: null dangling ImportBatchIds on restore. API/security: - API fail-closed by default: with no keys and AllowAnonymousApi off, /api/v1 returns 401 (protects /export and /import). New MeterVault:AllowAnonymousApi opt-in. - Cap /readings batch at 5000; report ignored (unknown-meter) count; enums as strings in JSON. +4 regression tests (guard window, API closed, /events 404, no demo double-count). 98 tests green; Docker deploy re-verified healthy with the API fail-closed. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
@@ -118,6 +118,7 @@ public sealed class ExportService(MeterVaultDbContext db)
|
||||
cost.Id = 0;
|
||||
cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null;
|
||||
cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
|
||||
cost.ImportBatchId = null; // the original import batch isn't part of the export
|
||||
_db.ManualCosts.Add(cost);
|
||||
}
|
||||
|
||||
@@ -125,6 +126,7 @@ public sealed class ExportService(MeterVaultDbContext db)
|
||||
{
|
||||
meterEvent.Id = 0;
|
||||
meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId);
|
||||
meterEvent.ImportBatchId = null;
|
||||
_db.MeterEvents.Add(meterEvent);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,24 +10,28 @@ namespace MeterVault.Infrastructure.Costing;
|
||||
/// Computes cost by joining bucketed consumption with time-ranged tariffs (SDD §7.5). Consumption
|
||||
/// is aggregated in SQL (Dapper, local-timezone buckets); the active price for each bucket is
|
||||
/// resolved in C# via <see cref="TariffResolver"/> using the month's dominant price (SDD §14.3).
|
||||
/// Categories roll up their member meters' costs plus meterless manual costs.
|
||||
/// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext
|
||||
/// factory (short-lived context per operation) so it is safe from a Blazor circuit.
|
||||
/// </summary>
|
||||
public sealed class CostService(MeterVaultDbContext db)
|
||||
public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync(
|
||||
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var meter = await _db.Meters.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meter = await db.Meters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
if (meter is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var series = await QueryConsumptionAsync(meterId, from, to, bucket, cancellationToken).ConfigureAwait(false);
|
||||
var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<MeterCostBucket>();
|
||||
foreach (var period in series.Keys.OrderBy(k => k))
|
||||
@@ -39,7 +43,8 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
var basePrice = TariffResolver.ResolveValue(tariffs, TariffComponent.BasePrice, meterId, meter.EnergyTypeId, mid);
|
||||
var feedIn = TariffResolver.ResolveValue(tariffs, TariffComponent.FeedIn, meterId, meter.EnergyTypeId, mid);
|
||||
|
||||
var cost = (consumption * unitPrice) + basePrice - (generation * feedIn);
|
||||
// Base price is a monthly standing charge: prorate it to the bucket length.
|
||||
var cost = (consumption * unitPrice) + (basePrice * MonthsInBucket(period, bucket)) - (generation * feedIn);
|
||||
results.Add(new MeterCostBucket(period, consumption, generation, cost));
|
||||
}
|
||||
|
||||
@@ -49,7 +54,9 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
public async Task<IReadOnlyList<CategoryCostBucket>> GetCategoryCostsAsync(
|
||||
int categoryId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var members = await _db.CostCategoryMembers
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var members = await db.CostCategoryMembers.AsNoTracking()
|
||||
.Where(m => m.CategoryId == categoryId)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -63,7 +70,7 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
|
||||
if (member.EnergyTypeId is { } energyTypeId)
|
||||
{
|
||||
var byType = await _db.Meters.Where(m => m.EnergyTypeId == energyTypeId).Select(m => m.Id)
|
||||
var byType = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).Select(m => m.Id)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
meterIds.UnionWith(byType);
|
||||
}
|
||||
@@ -78,9 +85,10 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
}
|
||||
}
|
||||
|
||||
var manualCosts = await _db.ManualCosts
|
||||
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= DateOnly.FromDateTime(from.UtcDateTime)
|
||||
&& c.PeriodStart < DateOnly.FromDateTime(to.UtcDateTime))
|
||||
var fromDate = DateOnly.FromDateTime(from.Date);
|
||||
var toDate = DateOnly.FromDateTime(to.Date);
|
||||
var manualCosts = await db.ManualCosts.AsNoTracking()
|
||||
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var cost in manualCosts)
|
||||
{
|
||||
@@ -91,8 +99,9 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
return [.. totals.OrderBy(kv => kv.Key).Select(kv => new CategoryCostBucket(kv.Key, kv.Value))];
|
||||
}
|
||||
|
||||
private async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
|
||||
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket, CancellationToken cancellationToken)
|
||||
private static async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
|
||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var interval = bucket switch
|
||||
{
|
||||
@@ -107,7 +116,7 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||
"GROUP BY period, kind";
|
||||
|
||||
var connection = _db.Database.GetDbConnection();
|
||||
var connection = db.Database.GetDbConnection();
|
||||
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<ConsumptionRow>(command).ConfigureAwait(false);
|
||||
|
||||
@@ -123,6 +132,13 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
return result;
|
||||
}
|
||||
|
||||
private static double MonthsInBucket(DateOnly period, CostBucket bucket) => bucket switch
|
||||
{
|
||||
CostBucket.Day => 1.0 / DateTime.DaysInMonth(period.Year, period.Month),
|
||||
CostBucket.Year => 12.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
private static DateOnly RepresentativeDate(DateOnly period, CostBucket bucket) => bucket switch
|
||||
{
|
||||
CostBucket.Day => period,
|
||||
|
||||
@@ -7,36 +7,41 @@ namespace MeterVault.Infrastructure.Dashboard;
|
||||
/// <summary>
|
||||
/// Read model for the dashboard (SDD §8): overview KPIs with period-over-period deltas, the cost
|
||||
/// breakdown by category, the "what cost more / less" difference view, and monthly trends. Reads
|
||||
/// only aggregated cost — never the raw hypertable.
|
||||
/// only aggregated cost — never the raw hypertable. Uses a DbContext factory (short-lived context
|
||||
/// per operation) so it is safe from a Blazor circuit.
|
||||
/// </summary>
|
||||
public sealed class DashboardService(MeterVaultDbContext db, CostService costService)
|
||||
public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
|
||||
{
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
private readonly CostService _costService = costService;
|
||||
|
||||
public async Task<DashboardSummary> GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var monthStart = new DateOnly(asOf.Year, asOf.Month, 1);
|
||||
var prevMonthStart = monthStart.AddMonths(-1);
|
||||
var yearStart = new DateOnly(asOf.Year, 1, 1);
|
||||
var prevYearStart = yearStart.AddYears(-1);
|
||||
|
||||
var month = new CostKpi(
|
||||
await TotalCostAsync(monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false),
|
||||
await TotalCostAsync(prevMonthStart, monthStart, cancellationToken).ConfigureAwait(false));
|
||||
await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false),
|
||||
await TotalCostAsync(db, prevMonthStart, monthStart, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
var year = new CostKpi(
|
||||
await TotalCostAsync(yearStart, yearStart.AddYears(1), cancellationToken).ConfigureAwait(false),
|
||||
await TotalCostAsync(prevYearStart, yearStart, cancellationToken).ConfigureAwait(false));
|
||||
await TotalCostAsync(db, yearStart, yearStart.AddYears(1), cancellationToken).ConfigureAwait(false),
|
||||
await TotalCostAsync(db, prevYearStart, yearStart, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
var latest = await LatestMonthCostAsync(cancellationToken).ConfigureAwait(false);
|
||||
var latest = await LatestMonthCostAsync(db, cancellationToken).ConfigureAwait(false);
|
||||
return new DashboardSummary(asOf, month, year, latest);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync(
|
||||
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var categories = await _db.CostCategories.OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
var categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var slices = new List<CategorySlice>();
|
||||
foreach (var category in categories)
|
||||
{
|
||||
@@ -55,12 +60,13 @@ public sealed class DashboardService(MeterVaultDbContext db, CostService costSer
|
||||
public async Task<IReadOnlyList<DifferenceRow>> GetCategoryDifferenceAsync(
|
||||
DateOnly currentStart, DateOnly previousStart, DateOnly span, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// span length in months from currentStart.
|
||||
var months = ((span.Year - currentStart.Year) * 12) + span.Month - currentStart.Month;
|
||||
var currentEnd = currentStart.AddMonths(Math.Max(1, months));
|
||||
var previousEnd = previousStart.AddMonths(Math.Max(1, months));
|
||||
|
||||
var categories = await _db.CostCategories.OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
var categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var rows = new List<DifferenceRow>();
|
||||
foreach (var category in categories)
|
||||
{
|
||||
@@ -78,8 +84,9 @@ public sealed class DashboardService(MeterVaultDbContext db, CostService costSer
|
||||
public async Task<IReadOnlyList<TrendPoint>> GetMonthlyTrendAsync(
|
||||
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
var totals = new Dictionary<DateOnly, double>();
|
||||
foreach (var meterId in await ActiveMeterIdsAsync(cancellationToken).ConfigureAwait(false))
|
||||
foreach (var meterId in await ActiveMeterIdsAsync(db, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
foreach (var bucket in await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
@@ -90,25 +97,25 @@ public sealed class DashboardService(MeterVaultDbContext db, CostService costSer
|
||||
return [.. totals.OrderBy(kv => kv.Key).Select(kv => new TrendPoint(kv.Key, kv.Value))];
|
||||
}
|
||||
|
||||
private async Task<double> TotalCostAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||
private async Task<double> TotalCostAsync(MeterVaultDbContext db, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||
{
|
||||
double total = 0;
|
||||
foreach (var meterId in await ActiveMeterIdsAsync(cancellationToken).ConfigureAwait(false))
|
||||
foreach (var meterId in await ActiveMeterIdsAsync(db, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var costs = await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false);
|
||||
total += costs.Sum(c => c.Cost);
|
||||
}
|
||||
|
||||
var manual = await _db.ManualCosts
|
||||
var manual = await db.ManualCosts.AsNoTracking()
|
||||
.Where(c => c.PeriodStart >= from && c.PeriodStart < to)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return total + (manual ?? 0);
|
||||
}
|
||||
|
||||
private async Task<double> LatestMonthCostAsync(CancellationToken cancellationToken)
|
||||
private async Task<double> LatestMonthCostAsync(MeterVaultDbContext db, CancellationToken cancellationToken)
|
||||
{
|
||||
var latest = await _db.Consumption
|
||||
var latest = await db.Consumption.AsNoTracking()
|
||||
.OrderByDescending(c => c.Time)
|
||||
.Select(c => (DateTimeOffset?)c.Time)
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -118,11 +125,11 @@ public sealed class DashboardService(MeterVaultDbContext db, CostService costSer
|
||||
}
|
||||
|
||||
var monthStart = new DateOnly(latest.Value.Year, latest.Value.Month, 1);
|
||||
return await TotalCostAsync(monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false);
|
||||
return await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<List<int>> ActiveMeterIdsAsync(CancellationToken cancellationToken) =>
|
||||
await _db.Meters.Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
private static async Task<List<int>> ActiveMeterIdsAsync(MeterVaultDbContext db, CancellationToken cancellationToken) =>
|
||||
await db.Meters.AsNoTracking().Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ public static class DependencyInjection
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
{
|
||||
services.AddDbContext<MeterVaultDbContext>(options =>
|
||||
// A factory (for per-operation contexts in Blazor components/read services — a shared
|
||||
// circuit-scoped DbContext is not thread-safe) plus a scoped context (from the factory)
|
||||
// for request/tick-scoped services (API, workers, importers) that inject it directly.
|
||||
services.AddDbContextFactory<MeterVaultDbContext>(options =>
|
||||
options
|
||||
.UseNpgsql(connectionString, npgsql =>
|
||||
npgsql.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
|
||||
.UseSnakeCaseNamingConvention());
|
||||
services.AddScoped<MeterVaultDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<MeterVaultDbContext>>().CreateDbContext());
|
||||
|
||||
services.AddSingleton<INormalizationEngine>(_ => NormalizationEngine.CreateDefault());
|
||||
services.AddScoped<NormalizationService>();
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class CsvImporter
|
||||
|
||||
foreach (var column in profile.Columns)
|
||||
{
|
||||
StageColumn(column, row, time, r, staged, previousByMeter);
|
||||
StageColumn(column, row, time, r, staged, previousByMeter, profile.DetectCumulativeSwaps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class CsvImporter
|
||||
}
|
||||
|
||||
private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time,
|
||||
int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter)
|
||||
int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
|
||||
{
|
||||
if (column.Role == MappingRole.Ignore || column.Index >= row.Count)
|
||||
{
|
||||
@@ -70,7 +70,7 @@ public sealed class CsvImporter
|
||||
switch (column.Role)
|
||||
{
|
||||
case MappingRole.Reading:
|
||||
StageReading(column, row, cell, time, rowIndex, staged, previousByMeter);
|
||||
StageReading(column, row, cell, time, rowIndex, staged, previousByMeter, detectSwaps);
|
||||
break;
|
||||
|
||||
case MappingRole.Delivery:
|
||||
@@ -126,7 +126,7 @@ public sealed class CsvImporter
|
||||
}
|
||||
|
||||
private static void StageReading(ColumnMapping column, IReadOnlyList<string> row, string cell,
|
||||
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter)
|
||||
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
|
||||
{
|
||||
var split = ValueCell.Split(cell);
|
||||
if (split is null)
|
||||
@@ -144,7 +144,7 @@ public sealed class CsvImporter
|
||||
$"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'.");
|
||||
}
|
||||
|
||||
if (previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
|
||||
if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
|
||||
{
|
||||
var override_ = ReadSwapOverride(column, row);
|
||||
staged.Events.Add(new MeterEvent
|
||||
|
||||
@@ -61,7 +61,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
|
||||
AddElectricityTariffs(electricity);
|
||||
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
|
||||
await LinkCategoriesAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, cancellationToken).ConfigureAwait(false);
|
||||
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
|
||||
// Kosten sheet (oil has no tariff). Linking a meter AND importing its Kosten column into the
|
||||
// same category would double-count, so we keep exactly one cost source per category.
|
||||
await LinkStromAndWasserAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, cancellationToken).ConfigureAwait(false);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meterIds = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, burner.Id);
|
||||
@@ -70,9 +73,19 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
await ImportSheetAsync(sampleDataDirectory, ElectricityFile, ReferenceProfiles.Electricity(meterIds), cancellationToken).ConfigureAwait(false);
|
||||
await ImportSheetAsync(sampleDataDirectory, WaterFile, ReferenceProfiles.Water(meterIds), cancellationToken).ConfigureAwait(false);
|
||||
await ImportSheetAsync(sampleDataDirectory, OilFile, ReferenceProfiles.HeatingOil(meterIds), cancellationToken).ConfigureAwait(false);
|
||||
await ImportSheetAsync(sampleDataDirectory, CostsFile, ReferenceProfiles.Costs(categoryIds), cancellationToken).ConfigureAwait(false);
|
||||
await ImportSheetAsync(sampleDataDirectory, CostsFile, HeizungCostsProfile(categoryIds.Heizung), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Kosten profile that imports only the Heizung column — Strom/Wasser are metered.</summary>
|
||||
private static MappingProfile HeizungCostsProfile(int heizungCategoryId) => new()
|
||||
{
|
||||
Name = "Energiebilanz — Kosten (Heizung)",
|
||||
DateColumn = 0,
|
||||
DateKind = DateKind.MonthName,
|
||||
FirstDataRowIndex = 1,
|
||||
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
|
||||
};
|
||||
|
||||
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(dir, file);
|
||||
@@ -121,8 +134,8 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
ValidFrom = validFrom,
|
||||
});
|
||||
|
||||
private async Task LinkCategoriesAsync(
|
||||
int haus, int netz, int auto, int solar1, int solar2, int wasser, int oilTank, CancellationToken cancellationToken)
|
||||
private async Task LinkStromAndWasserAsync(
|
||||
int haus, int netz, int auto, int solar1, int solar2, int wasser, CancellationToken cancellationToken)
|
||||
{
|
||||
var categories = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var meterId in new[] { haus, netz, auto, solar1, solar2 })
|
||||
@@ -131,7 +144,7 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
}
|
||||
|
||||
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Wasser, MeterId = wasser });
|
||||
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Heizung, MeterId = oilTank });
|
||||
// Heizung (oil) cost comes from the imported Kosten column, not a meter — no link (avoids double-count).
|
||||
}
|
||||
|
||||
private async Task<ReferenceCategoryIds> CategoryIdsAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -67,6 +67,13 @@ public sealed class HomeAssistantWorker(
|
||||
.Where(s => s.IsEnabled && s.SourceType == SourceType.HomeAssistant && s.EndpointId != null)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Drop schedule entries for sources that are gone/disabled so the dictionary doesn't grow.
|
||||
var liveIds = sources.Select(s => s.Id).ToHashSet();
|
||||
foreach (var staleId in _nextPoll.Keys.Where(id => !liveIds.Contains(id)).ToList())
|
||||
{
|
||||
_nextPoll.TryRemove(staleId, out _);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var source in sources)
|
||||
{
|
||||
|
||||
@@ -112,7 +112,7 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
||||
var previous = await _db.Readings
|
||||
.Where(r => r.MeterId == meterId && r.Time < time)
|
||||
.OrderByDescending(r => r.Time)
|
||||
.Select(r => (double?)r.Value)
|
||||
.Select(r => new { r.Value, r.Time })
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (previous is null || value >= previous.Value)
|
||||
@@ -120,11 +120,12 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
||||
return false;
|
||||
}
|
||||
|
||||
// A reset/swap event between the previous reading and this one explains the decrease.
|
||||
// Only a reset/swap in the window (previousReading, thisReading] explains the decrease —
|
||||
// an old historical reset must not permanently disable the guard.
|
||||
var explained = await _db.MeterEvents.AnyAsync(
|
||||
e => e.MeterId == meterId
|
||||
&& (e.EventType == MeterEventType.CounterReset || e.EventType == MeterEventType.MeterSwap)
|
||||
&& e.Time <= time,
|
||||
&& e.Time > previous.Time && e.Time <= time,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return !explained;
|
||||
|
||||
@@ -24,42 +24,52 @@ public sealed class MqttIngestionWorker(
|
||||
private readonly ILogger<MqttIngestionWorker> _logger = logger;
|
||||
private readonly MqttClientFactory _factory = new();
|
||||
private readonly ConcurrentDictionary<int, IMqttClient> _clients = new();
|
||||
private readonly ConcurrentDictionary<int, HashSet<string>> _subscribed = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(ReconnectInterval);
|
||||
do
|
||||
try
|
||||
{
|
||||
try
|
||||
do
|
||||
{
|
||||
await EnsureConnectionsAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "MQTT ingestion tick failed; will retry");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
|
||||
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (client.IsConnected)
|
||||
try
|
||||
{
|
||||
await client.DisconnectAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
|
||||
await EnsureConnectionsAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "MQTT ingestion tick failed; will retry");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown while idle waiting for the next tick.
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error disconnecting MQTT client during shutdown");
|
||||
}
|
||||
try
|
||||
{
|
||||
if (client.IsConnected)
|
||||
{
|
||||
await client.DisconnectAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error disconnecting MQTT client during shutdown");
|
||||
}
|
||||
|
||||
client.Dispose();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,13 +85,32 @@ public sealed class MqttIngestionWorker(
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var client = _clients.GetOrAdd(endpoint.Id, _ => CreateClient());
|
||||
if (client.IsConnected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var topics = await ResolveTopicsAsync(db, endpoint, cancellationToken).ConfigureAwait(false);
|
||||
await ConnectAndSubscribeAsync(endpoint, client, topics, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!client.IsConnected)
|
||||
{
|
||||
_subscribed[endpoint.Id] = [];
|
||||
await ConnectAndSubscribeAsync(endpoint, client, topics, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Already connected — subscribe to any topics added since the last tick.
|
||||
await SubscribeNewTopicsAsync(endpoint.Id, client, topics, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SubscribeNewTopicsAsync(
|
||||
int endpointId, IMqttClient client, IReadOnlyList<string> topics, CancellationToken cancellationToken)
|
||||
{
|
||||
var known = _subscribed.GetOrAdd(endpointId, _ => []);
|
||||
foreach (var topic in topics)
|
||||
{
|
||||
if (known.Add(topic))
|
||||
{
|
||||
await client.SubscribeAsync(topic, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("MQTT subscribed to new topic {Topic} on endpoint {Endpoint}", topic, endpointId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +143,11 @@ public sealed class MqttIngestionWorker(
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false);
|
||||
var known = _subscribed.GetOrAdd(endpoint.Id, _ => []);
|
||||
foreach (var topic in topics)
|
||||
{
|
||||
await client.SubscribeAsync(topic, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
known.Add(topic);
|
||||
}
|
||||
|
||||
_logger.LogInformation("MQTT connected to {Host}:{Port} ({TopicCount} topics)",
|
||||
|
||||
@@ -33,4 +33,10 @@ public sealed class MeterVaultOptions
|
||||
|
||||
/// <summary>Honour <c>X-Forwarded-User</c>/<c>Remote-User</c> from a trusted reverse proxy (SDD §10).</summary>
|
||||
public bool ReverseProxyTrust { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow the REST API without any API key. Off by default: with no keys configured the API is
|
||||
/// closed (401) rather than open, so an unconfigured deployment doesn't expose export/import.
|
||||
/// </summary>
|
||||
public bool AllowAnonymousApi { get; set; }
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Raw readings: 30-day chunks; the (meter_id, time) PK contains the partition column.
|
||||
// if_not_exists keeps a down-then-up cycle safe (Down leaves the hypertable in place).
|
||||
migrationBuilder.Sql(
|
||||
"SELECT create_hypertable('reading', 'time', chunk_time_interval => INTERVAL '30 days');");
|
||||
"SELECT create_hypertable('reading', 'time', chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);");
|
||||
|
||||
// Columnar compression, segmented by meter (monotonic sensor data compresses ~10-20x).
|
||||
migrationBuilder.Sql(
|
||||
@@ -29,11 +30,11 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
"timescaledb.compress_orderby = 'time DESC');");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"SELECT add_compression_policy('reading', INTERVAL '30 days');");
|
||||
"SELECT add_compression_policy('reading', INTERVAL '30 days', if_not_exists => true);");
|
||||
|
||||
// Normalized consumption: 90-day chunks. Kept effectively forever (small), so no compression policy.
|
||||
migrationBuilder.Sql(
|
||||
"SELECT create_hypertable('consumption', 'time', chunk_time_interval => INTERVAL '90 days');");
|
||||
"SELECT create_hypertable('consumption', 'time', chunk_time_interval => INTERVAL '90 days', if_not_exists => TRUE);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -40,8 +40,11 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
|
||||
private static void CreateAggregate(MigrationBuilder builder, string name, string bucket)
|
||||
{
|
||||
// IF NOT EXISTS + if_not_exists keep this re-run-safe: because the statements suppress
|
||||
// the transaction, a crash before __EFMigrationsHistory is written would otherwise brick
|
||||
// the next migrate by re-creating an object that already exists.
|
||||
builder.Sql(
|
||||
$"CREATE MATERIALIZED VIEW {name} WITH (timescaledb.continuous) AS " +
|
||||
$"CREATE MATERIALIZED VIEW IF NOT EXISTS {name} WITH (timescaledb.continuous) AS " +
|
||||
$"SELECT time_bucket(INTERVAL '{bucket}', time, '{Timezone}') AS bucket, " +
|
||||
"meter_id, kind, sum(amount) AS amount " +
|
||||
"FROM consumption GROUP BY bucket, meter_id, kind WITH NO DATA;",
|
||||
@@ -54,7 +57,8 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
$"SELECT add_continuous_aggregate_policy('{name}', " +
|
||||
$"start_offset => INTERVAL '{startOffset}', " +
|
||||
$"end_offset => INTERVAL '{endOffset}', " +
|
||||
"schedule_interval => INTERVAL '1 hour');",
|
||||
"schedule_interval => INTERVAL '1 hour', " +
|
||||
"if_not_exists => true);",
|
||||
suppressTransaction: true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user