ci / build-test (push) Successful in 2m41s
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.
407 lines
20 KiB
C#
407 lines
20 KiB
C#
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);
|
||
}
|
||
}
|