Files
MeterVault/src/Infrastructure/Dashboard/MeterDetailService.cs
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
Three things a reported Heizoel page got wrong at once. Its tank is dipped
a few times a year and its burner read every few months, which is exactly
the shape the coverage rules had not been walked through.

"No data" for data that exists. A tank books nothing until the next
dipstick closes the interval, so the stretch after the last dipstick is
covered by no run at all, and a bucket no run covers was reported missing.
The burner, whose run reaches into the window, said "only coarser data" --
the honest answer -- so one card claimed there was nothing while the
coverage panel beside it listed years of data. A bucket that no run covers,
no gap overlaps and no opening balance explains now reports the meter's
resolution when its preceding coverage is within one interval of its own
class: it is not silent, it is read rarely. A meter that does book its own
buckets and stops -- a dead hourly source, a sheet asked about a later
month -- still reads missing.

Auto answering twelve months with one bar. Coarse only means "longer than
a month", so a dipstick taken each autumn straddles a New Year as surely
as a month start: coarsening the chart to years bought nothing and cost
every point. The planning resolution now caps coarse at month when a run
crosses a local year edge, and a series that cannot resolve the natural
size no longer coarsens the whole chart -- it is drawn at that size with
its buckets marked, which the chart and table already explain.

A page contradicting itself. The comparison line above the ranking was fed
the leading measure's matched coverage but worded as if it spoke for the
page, directly above a burner row that did compare. It now names the figure
it is about.

Alongside: the "largest changes" ranking no longer drops a meter whose
change is not comparable. It ranks what can be ranked, then lists the rest
with their values and the reason -- the tank had been vanishing from its
own energy type. And every dated table now reads newest first, as lists
are read; charts stay chronological left to right, and the CSV export
stays ascending for spreadsheets.

A-41 to A-43 in the note record the three rules.
2026-09-20 12:44:32 +02:00

407 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>
/// Read model for the meter page (SDD §8.6, brief §7.2): the meter's identity, its raw readings, normalized rows and
/// events one bounded page at a time (D-50), the tariffs that apply to it, the context the manual-entry dialog judges an
/// entry against, and a virtual meter's calculation. The page's figures are not here — they come from the shared
/// analysis reader and cost engine for the selected period, like every other analysis page.
/// </summary>
/// <remarks>
/// <para>
/// Every record query is bounded: a page is <see cref="PageSize"/> rows, keyset-ordered newest first on the table's
/// key (a reading's instant; a consumption row's instant and kind; an event's instant and id), so paging is stable while
/// rows arrive and never scans what it skips. Counts stop at <see cref="CountCap"/>. This is the only place the UI reads
/// raw rows (D-57: raw readings are kept, and are the audit record, not the analytical history).
/// </para>
/// <para>A context per call (DbContext factory), so it is safe on a Blazor circuit.</para>
/// </remarks>
public sealed class MeterDetailService
{
/// <summary>Rows per page of a record tab (D-50).</summary>
public const int PageSize = 100;
/// <summary>Counts stop here: "10,000+" says enough, and counting further is only a slower query.</summary>
public const int CountCap = 10_000;
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory;
private readonly AnalysisReader _reader;
private readonly TimeZoneInfo _zone;
/// <param name="contextFactory">The database.</param>
/// <param name="options">The instance options; the zone orders readings as the normalizer does (UTC without options).</param>
/// <param name="reader">The analysis reader whose catalog validates a calculation; one on the same options by default.</param>
public MeterDetailService(
IDbContextFactory<MeterVaultDbContext> contextFactory,
IOptions<MeterVaultOptions>? options = null,
AnalysisReader? reader = null)
{
ArgumentNullException.ThrowIfNull(contextFactory);
_contextFactory = contextFactory;
_reader = reader ?? new AnalysisReader(contextFactory, options);
_zone = InstanceTimeZone.Resolve(options?.Value.TimeZone);
}
/// <summary>The meter's identity and how it is fed, or null when it does not exist.</summary>
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)
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
if (meter is null)
{
return null;
}
var readings = db.Readings.AsNoTracking().Where(r => r.MeterId == meterId);
var first = await readings.OrderBy(r => r.Time).Select(r => new RegisterPoint(r.Time, r.Value))
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
var last = await readings.OrderByDescending(r => r.Time).Select(r => new RegisterPoint(r.Time, r.Value))
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
var hasEvents = await db.MeterEvents.AsNoTracking().AnyAsync(e => e.MeterId == meterId, cancellationToken).ConfigureAwait(false);
var sourceCount = await db.MeterSources.AsNoTracking().CountAsync(s => s.MeterId == meterId, cancellationToken).ConfigureAwait(false);
var tank = meter.Mode == MeterMode.Virtual
? null
: await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false);
// What the normalized amounts measure (D-20). A virtual meter's is its declared result; the catalog validates it
// (GetCalculationAsync) — here the declaration is enough to label it.
var declared = meter.Mode == MeterMode.Virtual ? VirtualDefinitionJson.Read(meter.Meta).Definition?.DeclaredResult : null;
var quantity = NormalizedQuantity.Of(meter, tank, declared);
return new MeterDetailView(
meter.Id,
meter.Name,
meter.EnergyTypeId,
meter.EnergyType?.DisplayName ?? string.Empty,
meter.Mode,
meter.Unit,
meter.Location,
meter.SerialNumber,
meter.Manufacturer,
meter.Model,
meter.InitialBaseline,
meter.IsActive,
meter.InstalledAt,
meter.RetiredAt,
tank is not null,
sourceCount,
first is not null,
hasEvents,
first,
last,
quantity.Kind,
quantity.Unit);
}
/// <summary>One page of the meter's raw readings in <paramref name="range"/>, newest first (D-50).</summary>
/// <param name="meterId">The meter.</param>
/// <param name="range">The date filter.</param>
/// <param name="after">Where the page starts: the previous page's <see cref="RecordPage{T}.Next"/>; null for the newest rows.</param>
/// <param name="cancellationToken">Cancels the queries.</param>
public async Task<RecordPage<ReadingRow>> GetReadingsAsync(
int meterId, RecordRange range, RecordCursor? after = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(range);
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var rows = db.Readings.AsNoTracking().Where(r => r.MeterId == meterId);
if (range.From is { } from)
{
rows = rows.Where(r => r.Time >= from);
}
if (range.To is { } to)
{
rows = rows.Where(r => r.Time < to);
}
var total = await rows.Take(CountCap + 1).CountAsync(cancellationToken).ConfigureAwait(false);
var page = rows;
if (after is { } cursor)
{
// A reading's key is (meter, instant): the instant alone orders it.
page = page.Where(r => r.Time < cursor.Time);
}
var list = await page.OrderByDescending(r => r.Time).Take(PageSize + 1)
.Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags))
.ToListAsync(cancellationToken).ConfigureAwait(false);
return Page(list, total, r => new RecordCursor(r.Time));
}
/// <summary>One page of the meter's normalized rows in <paramref name="range"/>, newest first (D-50).</summary>
public async Task<RecordPage<ConsumptionDetailRow>> GetConsumptionAsync(
int meterId, RecordRange range, RecordCursor? after = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(range);
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var rows = db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId);
if (range.From is { } from)
{
rows = rows.Where(c => c.Time >= from);
}
if (range.To is { } to)
{
rows = rows.Where(c => c.Time < to);
}
var total = await rows.Take(CountCap + 1).CountAsync(cancellationToken).ConfigureAwait(false);
var page = rows;
if (after is { } cursor)
{
// Key (meter, instant, kind): at one instant a meter can book consumption and generation.
var kind = (ConsumptionKind)cursor.Tiebreak;
page = page.Where(c => c.Time < cursor.Time || (c.Time == cursor.Time && c.Kind < kind));
}
var list = await page.OrderByDescending(c => c.Time).ThenByDescending(c => c.Kind).Take(PageSize + 1)
.Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality))
.ToListAsync(cancellationToken).ConfigureAwait(false);
return Page(list, total, c => new RecordCursor(c.Time, (int)c.Kind));
}
/// <summary>One page of the meter's events in <paramref name="range"/>, newest first (D-50).</summary>
public async Task<RecordPage<EventRow>> GetEventsAsync(
int meterId, RecordRange range, RecordCursor? after = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(range);
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var rows = EventsIn(db, meterId, range);
var total = await rows.Take(CountCap + 1).CountAsync(cancellationToken).ConfigureAwait(false);
var page = rows;
if (after is { } cursor)
{
var id = cursor.Tiebreak;
page = page.Where(e => e.Time < cursor.Time || (e.Time == cursor.Time && e.Id < id));
}
var list = await page.OrderByDescending(e => e.Time).ThenByDescending(e => e.Id).Take(PageSize + 1)
.Select(e => new EventRow(e.Id, e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes, e.ImportBatchId))
.ToListAsync(cancellationToken).ConfigureAwait(false);
return Page(list, total, e => new RecordCursor(e.Time, e.Id));
}
/// <summary>
/// The tariffs that can price the meter — its own, its energy type's and the global ones — by component, then
/// scope, then start date with the newest first (A-42). All of them, not only those of a period: a price history
/// is short.
/// </summary>
public async Task<IReadOnlyList<TariffRow>> GetTariffsAsync(int meterId, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var energyTypeId = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => (short?)m.EnergyTypeId)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (energyTypeId is not { } typeId)
{
return [];
}
return await TariffsOf(db, meterId, typeId)
.OrderBy(t => t.Component).ThenBy(t => t.ScopeType).ThenByDescending(t => t.ValidFrom)
.Select(t => new TariffRow(t.Id, t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// The events and tariff changes inside <paramref name="range"/> (brief §7.2 contextual markers), at most
/// <paramref name="maxEvents"/> events. Both lists are newest first (A-42), so the cap keeps the recent ones.
/// </summary>
/// <param name="meterId">The meter.</param>
/// <param name="range">The analysis range.</param>
/// <param name="firstDay">The range's first local day: tariffs starting on or after it count.</param>
/// <param name="lastDay">The range's last local day.</param>
/// <param name="maxEvents">The most events listed.</param>
/// <param name="cancellationToken">Cancels the queries.</param>
public async Task<MeterMarkers> GetMarkersAsync(
int meterId, RecordRange range, DateOnly firstDay, DateOnly lastDay, int maxEvents = 12, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(range);
ArgumentOutOfRangeException.ThrowIfNegative(maxEvents);
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var energyTypeId = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => (short?)m.EnergyTypeId)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (energyTypeId is not { } typeId)
{
return MeterMarkers.None;
}
var events = await EventsIn(db, meterId, range)
.OrderByDescending(e => e.Time).ThenByDescending(e => e.Id).Take(maxEvents + 1)
.Select(e => new EventRow(e.Id, e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes, e.ImportBatchId))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var tariffs = await TariffsOf(db, meterId, typeId)
.Where(t => t.ValidFrom >= firstDay && t.ValidFrom <= lastDay)
.OrderByDescending(t => t.ValidFrom).ThenBy(t => t.Component)
.Take(maxEvents)
.Select(t => new TariffRow(t.Id, t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var more = events.Count > maxEvents;
return new MeterMarkers(more ? events[..maxEvents] : events, more, tariffs);
}
/// <summary>
/// What the manual-entry dialog judges a reading at <paramref name="at"/> against (D-50): read on its own, with the
/// neighbours the ingestion guard uses, so the dialog's warning never depends on a page of rows. Null when the meter
/// is gone.
/// </summary>
public async Task<ReadingEntryContext?> GetReadingEntryContextAsync(
int meterId, DateTimeOffset at, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var meter = await db.Meters.AsNoTracking()
.Where(m => m.Id == meterId)
.Select(m => new { m.Mode, m.Unit, m.InitialBaseline })
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (meter is null)
{
return null;
}
var utc = at.ToUniversalTime();
var readings = db.Readings.AsNoTracking().Where(r => r.MeterId == meterId);
var latest = await readings.OrderByDescending(r => r.Time).Select(r => new RegisterPoint(r.Time, r.Value))
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
var atTime = await readings.Where(r => r.Time == utc)
.Select(r => new ExistingReading(r.Time, r.Value, r.Quality, r.Flags))
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
// The neighbours on the normalizer's timeline — a month row stamped on the 1st describes the end of its month —
// and the swaps or resets between them, exactly as IngestionService's decrease guard reads them.
var neighbours = await RegisterNeighbours.FindAsync(db, meterId, utc, _zone, cancellationToken).ConfigureAwait(false);
var previous = neighbours.Previous is { } p ? new RegisterPoint(p.Reading.Time, p.Reading.Value) : null;
return new ReadingEntryContext(
meterId,
utc,
meter.Unit,
meter.InitialBaseline,
MeterEventRules.IsMonotonic(meter.Mode),
latest,
previous,
neighbours.BoundaryAfterPreviousUpTo(utc),
atTime);
}
/// <summary>
/// The names of <paramref name="meterIds"/> (user data, never translated) — for a figure or an attention item that
/// speaks of a meter the page's own result does not name, such as the other end of a dependency loop. Unknown ids are
/// left out.
/// </summary>
public async Task<IReadOnlyDictionary<int, string>> GetMeterNamesAsync(
IEnumerable<int> meterIds, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(meterIds);
var ids = meterIds.Distinct().ToArray();
if (ids.Length == 0)
{
return new Dictionary<int, string>();
}
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await db.Meters.AsNoTracking()
.Where(m => ids.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => m.Name, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// A virtual meter's calculation as the analysis reader sees it (D-26 D-28): validated against the current
/// catalog, with the sources' names, kinds and units. Null for a meter that does not exist or is not virtual.
/// </summary>
public async Task<MeterCalculationView?> GetCalculationAsync(int meterId, CancellationToken cancellationToken = default)
{
var catalog = await _reader.LoadCatalogAsync(cancellationToken).ConfigureAwait(false);
return CalculationOf(catalog, meterId);
}
/// <summary>The calculation of <paramref name="meterId"/> in a loaded catalog; null unless it is a virtual meter.</summary>
public static MeterCalculationView? CalculationOf(AnalysisCatalog catalog, int meterId)
{
ArgumentNullException.ThrowIfNull(catalog);
if (catalog.Find(meterId) is not { IsVirtual: true } meter)
{
return null;
}
var validation = meter.Validation;
var status = meter.VirtualStatus ?? VirtualMeterStatus.NeedsConfiguration;
var definition = meter.Definition;
var referenced = definition?.ReferencedMeterIds ?? (IReadOnlyList<int>)(meter.Legacy?.MeterIds ?? []);
var names = new Dictionary<int, string>();
foreach (var entry in catalog.Meters.Values)
{
names[entry.Id] = entry.Name;
}
CalculationSource Source(int id) => catalog.Find(id) is { } source
? new CalculationSource(id, source.Name, source.Meter.Mode, source.Quantity.Kind, source.Quantity.Unit, Exists: true)
: new CalculationSource(id, string.Empty, MeterMode.Virtual, QuantityKind.Consumption, string.Empty, Exists: false);
var problems = validation?.Problems ?? [];
return new MeterCalculationView(
meter.Id,
status,
definition?.Expression,
validation?.Kind ?? meter.Quantity.Kind,
validation?.Unit ?? meter.Quantity.Unit,
meter.CostRule,
meter.StoredDefinition?.Definition?.CostRule,
[.. referenced.Distinct().Order().Select(Source)],
[.. catalog.PhysicalLeaves(referenced).Select(Source)],
problems,
validation?.CostRuleProblem,
meter.Legacy,
names);
}
private static IQueryable<MeterEvent> EventsIn(MeterVaultDbContext db, int meterId, RecordRange range)
{
var rows = db.MeterEvents.AsNoTracking().Where(e => e.MeterId == meterId);
if (range.From is { } from)
{
rows = rows.Where(e => e.Time >= from);
}
if (range.To is { } to)
{
rows = rows.Where(e => e.Time < to);
}
return rows;
}
private static IQueryable<Tariff> TariffsOf(MeterVaultDbContext db, int meterId, short energyTypeId) =>
db.Tariffs.AsNoTracking().Where(t => t.ScopeType == TariffScope.Global
|| (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId)
|| (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId));
/// <summary>A page from <see cref="PageSize"/> + 1 fetched rows: the extra row only says there is a next page.</summary>
private static RecordPage<T> Page<T>(List<T> rows, int total, Func<T, RecordCursor> cursorOf)
{
var more = rows.Count > PageSize;
var page = more ? rows[..PageSize] : rows;
return new RecordPage<T>(page, more ? cursorOf(page[^1]) : null, Math.Min(total, CountCap), total > CountCap);
}
}