@page "/meters" @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory @inject MeterVault.Core.Normalization.INormalizationEngine Engine @inject ISnackbar Snackbar @inject IDialogService DialogService @using Microsoft.EntityFrameworkCore @using MudBlazor MeterVault — Meters
Meters Add meter
@if (_meters is null) { } else { Name Type Mode Unit Sources Last seen Active Actions @context.Name @context.EnergyType?.DisplayName @context.Mode @context.Unit @context.Sources.Count @{ var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max(); } @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—") @(context.IsActive ? "yes" : "no") @if (_meters.Count == 0) { No meters yet. Add one, or go to Import to load the reference data. } } @(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}") @foreach (var t in _energyTypes) { @t.DisplayName } @foreach (var mode in Enum.GetValues()) { @mode } @if (_working.Mode == MeterMode.Virtual) { Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2). } else if (_working.Mode == MeterMode.InstantRate) { Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption. Store the value as a per-hour rate in this meter's unit (e.g. kW for kWh, L/h for L): a source reporting W or L/min should carry a scale factor to convert it first. } — none — total_load grid_import grid_export @foreach (var m in AvailableUpstream()) { @m.Name }
@if (_working.Id != 0 && _working.RecomputeNeeded) { Mode/baseline changed — consumption will be recomputed on save. }
Cancel Save
@code { private List? _meters; private List _energyTypes = []; private List _allLinks = []; private bool _editOpen; private EditModel _working = new(); private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; protected override Task OnInitializedAsync() => LoadAsync(); private async Task LoadAsync() { await using var db = await DbFactory.CreateDbContextAsync(); _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync(); _allLinks = await db.MeterLinks.AsNoTracking().ToListAsync(); _meters = await db.Meters .AsNoTracking() .Include(m => m.EnergyType) .Include(m => m.Sources) .OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name) .ToListAsync(); } // Upstream candidates: same energy type, not self, and not a descendant (would create a cycle). private IEnumerable AvailableUpstream() { if (_meters is null) { return []; } var descendants = Descendants(_working.Id); return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id)); } private HashSet Descendants(int meterId) { var result = new HashSet(); if (meterId == 0) { return result; } var queue = new Queue(); queue.Enqueue(meterId); while (queue.Count > 0) { var current = queue.Dequeue(); foreach (var link in _allLinks.Where(l => l.FromMeterId == current)) { if (result.Add(link.ToMeterId)) { queue.Enqueue(link.ToMeterId); } } } return result; } private string UpstreamText(IReadOnlyList ids) { var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText); return string.Join(", ", names); } private void OpenEdit(Meter? meter) { if (meter is null) { _working = new EditModel { EnergyTypeId = _energyTypes.FirstOrDefault()?.Id ?? 0 }; } else { _working = new EditModel { Id = meter.Id, Name = meter.Name, EnergyTypeId = meter.EnergyTypeId, Mode = meter.Mode, OriginalMode = meter.Mode, Unit = meter.Unit, InitialBaseline = meter.InitialBaseline, OriginalBaseline = meter.InitialBaseline, Role = MeterMeta.Role(meter.Meta) ?? "", Location = meter.Location, SerialNumber = meter.SerialNumber, Manufacturer = meter.Manufacturer, Model = meter.Model, IsActive = meter.IsActive, Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(), }; } _editOpen = true; } private async Task SaveAsync() { if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0) { Snackbar.Add("Name, energy type and unit are required.", Severity.Warning); return; } await using var db = await DbFactory.CreateDbContextAsync(); int meterId; if (_working.Id == 0) { var meter = new Meter { Name = _working.Name.Trim(), EnergyTypeId = _working.EnergyTypeId, Mode = _working.Mode, Unit = _working.Unit.Trim(), InitialBaseline = _working.InitialBaseline, Meta = MeterMeta.SetRole("{}", _working.Role), Location = Trim(_working.Location), SerialNumber = Trim(_working.SerialNumber), Manufacturer = Trim(_working.Manufacturer), Model = Trim(_working.Model), IsActive = _working.IsActive, }; db.Meters.Add(meter); await db.SaveChangesAsync(); meterId = meter.Id; } else { await using var tx = await db.Database.BeginTransactionAsync(); var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id); existing.Name = _working.Name.Trim(); existing.EnergyTypeId = _working.EnergyTypeId; existing.Mode = _working.Mode; existing.Unit = _working.Unit.Trim(); existing.InitialBaseline = _working.InitialBaseline; existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role); existing.Location = Trim(_working.Location); existing.SerialNumber = Trim(_working.SerialNumber); existing.Manufacturer = Trim(_working.Manufacturer); existing.Model = Trim(_working.Model); existing.IsActive = _working.IsActive; existing.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); if (_working.RecomputeNeeded) { var normalization = new MeterVault.Infrastructure.Normalization.NormalizationService(db, Engine); await normalization.RecomputeMeterAsync(existing.Id, null); await db.SaveChangesAsync(); } await tx.CommitAsync(); meterId = existing.Id; } await SyncUpstreamAsync(db, meterId, _working.Upstream); _editOpen = false; Snackbar.Add("Saved.", Severity.Success); await LoadAsync(); } /// Reconciles the meter's incoming flow links to the selected upstream meters. private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable desiredUpstream) { var desired = desiredUpstream.Where(id => id != meterId).ToHashSet(); var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync(); foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId))) { db.MeterLinks.Remove(link); } foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id))) { db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId }); } await db.SaveChangesAsync(); } private async Task DeleteAsync(Meter meter) { await using var db = await DbFactory.CreateDbContextAsync(); var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id); var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id); var detail = readings + consumption > 0 ? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)." : ""; if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone.")) { return; } await using var tx = await db.Database.BeginTransactionAsync(); // reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade. await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync(); await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync(); await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync(); await tx.CommitAsync(); Snackbar.Add("Deleted.", Severity.Success); await LoadAsync(); } private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private sealed class EditModel { public int Id { get; set; } public string Name { get; set; } = ""; public short EnergyTypeId { get; set; } public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter; public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter; public string Unit { get; set; } = ""; public double InitialBaseline { get; set; } public double OriginalBaseline { get; set; } public string Role { get; set; } = ""; public string? Location { get; set; } public string? SerialNumber { get; set; } public string? Manufacturer { get; set; } public string? Model { get; set; } public bool IsActive { get; set; } = true; public IReadOnlyCollection Upstream { get; set; } = new HashSet(); public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9; } }