using MeterVault.Core.Domain; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Infrastructure.Ingestion; /// The outcome of ingesting one reading. public enum IngestionOutcome { Written, Updated, RejectedDecrease, UnknownSource, } /// /// Persists a single incoming reading (SDD §6.1, FR-4): applies the source's scale/offset, is /// idempotent on (meter_id, time), and guards monotonic registers against spurious decreases /// unless an active reset/swap event explains them. Updates the source's last-seen status. /// Consumption normalization is recomputed separately (batch/scheduled), not per message. /// public sealed class IngestionService( MeterVaultDbContext db, Normalization.NormalizationService normalization) { private static readonly HashSet MonotonicModes = [MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter]; private readonly MeterVaultDbContext _db = db; private readonly Normalization.NormalizationService _normalization = normalization; /// Ingests through a configured source (MQTT/HA workers): applies scale/offset and updates source status. public async Task IngestAsync( int sourceId, DateTimeOffset time, double rawValue, CancellationToken cancellationToken = default) { var source = await _db.MeterSources .FirstOrDefaultAsync(s => s.Id == sourceId, cancellationToken).ConfigureAwait(false); if (source is null) { return IngestionOutcome.UnknownSource; } var meter = await _db.Meters .FirstOrDefaultAsync(m => m.Id == source.MeterId, cancellationToken).ConfigureAwait(false); if (meter is null) { return IngestionOutcome.UnknownSource; } var value = (rawValue * source.Scale) + source.Offset; var utc = time.ToUniversalTime(); if (MonotonicModes.Contains(meter.Mode) && await IsSpuriousDecreaseAsync(meter.Id, utc, value, cancellationToken).ConfigureAwait(false)) { await UpdateSourceStatusAsync(source, utc, value, "rejected: decrease", cancellationToken).ConfigureAwait(false); return IngestionOutcome.RejectedDecrease; } var outcome = await UpsertAsync(meter, utc, value, source.Id, cancellationToken).ConfigureAwait(false); await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false); await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false); return outcome; } /// Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings). public async Task IngestByMeterAsync( int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken = default) { var meter = await _db.Meters .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); if (meter is null) { return IngestionOutcome.UnknownSource; } var utc = time.ToUniversalTime(); if (MonotonicModes.Contains(meter.Mode) && await IsSpuriousDecreaseAsync(meter.Id, utc, value, cancellationToken).ConfigureAwait(false)) { return IngestionOutcome.RejectedDecrease; } var outcome = await UpsertAsync(meter, utc, value, sourceId: null, cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false); return outcome; } /// /// Derives consumption from the reading just written. Without this a live-ingested reading sits /// in reading forever and every derived figure — consumption, generation, cost — stays /// frozen at the last import, because nothing else recomputes that meter. /// /// /// Recomputes inline rather than on a debounce. /// rewrites a meter's whole consumption series, which is cheap at metering cadence — HA polls /// hourly — but would be wasteful under a chatty MQTT source publishing every few seconds. If /// such a source is ever added, batch this behind a dirty-set worker rather than making the /// normalizer incremental: consumption being a pure function of readings + events is what makes /// it reproducible. /// private async Task RenormalizeAsync( int meterId, IngestionOutcome outcome, CancellationToken cancellationToken) { // A rejected decrease changed nothing, so the existing series is still correct. if (outcome is not (IngestionOutcome.Written or IngestionOutcome.Updated)) { return; } // The reading must already be persisted: RecomputeMeterAsync re-reads the meter's readings // from the database, so anything still pending in the change tracker would be missed. await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } private async Task UpsertAsync( Meter meter, DateTimeOffset utc, double value, int? sourceId, CancellationToken cancellationToken) { var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc) ?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false); if (existing is null) { _db.Readings.Add(new Reading { MeterId = meter.Id, Time = utc, Value = value, SourceId = sourceId, Quality = ReadingQuality.Measured, }); return IngestionOutcome.Written; } existing.Value = value; existing.SourceId = sourceId ?? existing.SourceId; return IngestionOutcome.Updated; } private async Task IsSpuriousDecreaseAsync( int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken) { var previous = await _db.Readings .Where(r => r.MeterId == meterId && r.Time < time) .OrderByDescending(r => r.Time) .Select(r => new { r.Value, r.Time }) .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (previous is null || value >= previous.Value) { return false; } // 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 > previous.Time && e.Time <= time, cancellationToken).ConfigureAwait(false); return !explained; } private async Task UpdateSourceStatusAsync( MeterSource source, DateTimeOffset time, double value, string status, CancellationToken cancellationToken) { source.LastSeenAt = time; source.LastValue = value; source.LastStatus = status; await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } }