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);
}
}