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; /// /// 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. /// /// /// /// Every record query is bounded: a page is 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 . 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). /// /// A context per call (DbContext factory), so it is safe on a Blazor circuit. /// public sealed class MeterDetailService { /// Rows per page of a record tab (D-50). public const int PageSize = 100; /// Counts stop here: "10,000+" says enough, and counting further is only a slower query. public const int CountCap = 10_000; private readonly IDbContextFactory _contextFactory; private readonly AnalysisReader _reader; private readonly TimeZoneInfo _zone; /// The database. /// The instance options; the zone orders readings as the normalizer does (UTC without options). /// The analysis reader whose catalog validates a calculation; one on the same options by default. public MeterDetailService( IDbContextFactory contextFactory, IOptions? options = null, AnalysisReader? reader = null) { ArgumentNullException.ThrowIfNull(contextFactory); _contextFactory = contextFactory; _reader = reader ?? new AnalysisReader(contextFactory, options); _zone = InstanceTimeZone.Resolve(options?.Value.TimeZone); } /// The meter's identity and how it is fed, or null when it does not exist. public async Task 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); } /// One page of the meter's raw readings in , newest first (D-50). /// The meter. /// The date filter. /// Where the page starts: the previous page's ; null for the newest rows. /// Cancels the queries. public async Task> 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)); } /// One page of the meter's normalized rows in , newest first (D-50). public async Task> 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)); } /// One page of the meter's events in , newest first (D-50). public async Task> 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)); } /// /// 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. /// public async Task> 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); } /// /// The events and tariff changes inside (brief §7.2 contextual markers), at most /// events. Both lists are newest first (A-42), so the cap keeps the recent ones. /// /// The meter. /// The analysis range. /// The range's first local day: tariffs starting on or after it count. /// The range's last local day. /// The most events listed. /// Cancels the queries. public async Task 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); } /// /// What the manual-entry dialog judges a reading 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. /// public async Task 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); } /// /// The names of (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. /// public async Task> GetMeterNamesAsync( IEnumerable meterIds, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(meterIds); var ids = meterIds.Distinct().ToArray(); if (ids.Length == 0) { return new Dictionary(); } 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); } /// /// 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. /// public async Task GetCalculationAsync(int meterId, CancellationToken cancellationToken = default) { var catalog = await _reader.LoadCatalogAsync(cancellationToken).ConfigureAwait(false); return CalculationOf(catalog, meterId); } /// The calculation of in a loaded catalog; null unless it is a virtual meter. 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)(meter.Legacy?.MeterIds ?? []); var names = new Dictionary(); 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 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 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)); /// A page from + 1 fetched rows: the extra row only says there is a next page. private static RecordPage Page(List rows, int total, Func cursorOf) { var more = rows.Count > PageSize; var page = more ? rows[..PageSize] : rows; return new RecordPage(page, more ? cursorOf(page[^1]) : null, Math.Min(total, CountCap), total > CountCap); } }