Files
MeterVault/src/Infrastructure/Ingestion/IngestionService.cs
T
schmidt.florian 62d102c335
ci / build-test (push) Successful in 1m13s
Ingestion: derive consumption on ingest, and poll HA in minutes not seconds
Live ingestion wrote the raw reading and stopped there. Import, the REST push
endpoint and the meter editor all recompute afterwards; the MQTT/Tasmota/HA
path was the one that did not, so a polled reading landed in `reading` and
every derived figure stayed frozen at the last import. Observed on a
GenerationCounter: 45 readings, 44 consumption rows, generation pinned to the
register value of the last imported reading.

Recompute inline rather than behind a debounce. Normalizing a whole meter is
cheap at metering cadence and a background dirty-set worker is machinery this
does not yet need; the remark on RenormalizeAsync records when it would.

Fixes a latent bug this surfaced in NormalizationService: ExecuteDelete drops
the consumption rows in the database but leaves them in the change tracker, so
a second recompute on the same context threw an identity conflict on
(meter, time, kind). One worker scope ingesting two readings was enough to hit
it. Detach the stale entries after the delete.

Poll interval is now minutes, default 60, replacing seconds/60. A meter answers
"how much this month, what will it cost" — an hourly sample answers that
exactly as well as a per-second one, with far less raw volume (SDD §5.5). The
`pollSeconds` key no longer binds, so existing sources fall back to the 60
default and move from every-60-seconds to hourly, which is the intent. A source
that had deliberately set e.g. 300 seconds also lands on 60 minutes.

Two test cleanups now delete consumption before the meter: live ingestion never
produced any before, so the FK had nothing to trip on.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 18:45:57 +02:00

175 lines
7.6 KiB
C#

using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>The outcome of ingesting one reading.</summary>
public enum IngestionOutcome
{
Written,
Updated,
RejectedDecrease,
UnknownSource,
}
/// <summary>
/// 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.
/// </summary>
public sealed class IngestionService(
MeterVaultDbContext db, Normalization.NormalizationService normalization)
{
private static readonly HashSet<MeterMode> MonotonicModes =
[MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter];
private readonly MeterVaultDbContext _db = db;
private readonly Normalization.NormalizationService _normalization = normalization;
/// <summary>Ingests through a configured source (MQTT/HA workers): applies scale/offset and updates source status.</summary>
public async Task<IngestionOutcome> 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;
}
/// <summary>Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings).</summary>
public async Task<IngestionOutcome> 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;
}
/// <summary>
/// Derives consumption from the reading just written. Without this a live-ingested reading sits
/// in <c>reading</c> forever and every derived figure — consumption, generation, cost — stays
/// frozen at the last import, because nothing else recomputes that meter.
/// </summary>
/// <remarks>
/// Recomputes inline rather than on a debounce. <see cref="Normalization.NormalizationService"/>
/// 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.
/// </remarks>
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<IngestionOutcome> 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<bool> 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);
}
}