The instant_rate mode existed in the enum but had no normalizer, so normalizing a power/flow sensor threw NotSupportedException. InstantRateNormalizer integrates the rate over time (trapezoidal, attributed to each interval's end reading); a per-hour rate in the meter's unit yields the consumption unit (kW->kWh, L/h->L). Registered in the engine; meter editor shows a mode hint. +4 Core tests. Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB
360 lines
15 KiB
Plaintext
360 lines
15 KiB
Plaintext
@page "/meters"
|
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
|
@inject MeterVault.Core.Normalization.INormalizationEngine Engine
|
|
@inject ISnackbar Snackbar
|
|
@inject IDialogService DialogService
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using MudBlazor
|
|
|
|
<PageTitle>MeterVault — Meters</PageTitle>
|
|
|
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
|
<MudText Typo="Typo.h4">Meters</MudText>
|
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
|
Add meter
|
|
</MudButton>
|
|
</div>
|
|
|
|
@if (_meters is null)
|
|
{
|
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
|
}
|
|
else
|
|
{
|
|
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
|
|
<HeaderContent>
|
|
<MudTh>Name</MudTh>
|
|
<MudTh>Type</MudTh>
|
|
<MudTh>Mode</MudTh>
|
|
<MudTh>Unit</MudTh>
|
|
<MudTh>Sources</MudTh>
|
|
<MudTh>Last seen</MudTh>
|
|
<MudTh>Active</MudTh>
|
|
<MudTh Style="text-align:right">Actions</MudTh>
|
|
</HeaderContent>
|
|
<RowTemplate>
|
|
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
|
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
|
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
|
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
|
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
|
<MudTd DataLabel="Last seen">
|
|
@{
|
|
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
|
|
}
|
|
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
|
</MudTd>
|
|
<MudTd DataLabel="Active">@(context.IsActive ? "yes" : "no")</MudTd>
|
|
<MudTd DataLabel="Actions" Style="text-align:right">
|
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
|
</MudTd>
|
|
</RowTemplate>
|
|
</MudTable>
|
|
|
|
@if (_meters.Count == 0)
|
|
{
|
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
|
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
|
</MudAlert>
|
|
}
|
|
}
|
|
|
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
|
<TitleContent>
|
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText>
|
|
</TitleContent>
|
|
<DialogContent>
|
|
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
|
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="Energy type" Class="mb-2">
|
|
@foreach (var t in _energyTypes)
|
|
{
|
|
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Measurement mode" Class="mb-2">
|
|
@foreach (var mode in Enum.GetValues<MeterMode>())
|
|
{
|
|
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
@if (_working.Mode == MeterMode.Virtual)
|
|
{
|
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
|
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).
|
|
</MudAlert>
|
|
}
|
|
else if (_working.Mode == MeterMode.InstantRate)
|
|
{
|
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
|
Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption.
|
|
Store the value as a <b>per-hour</b> 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.
|
|
</MudAlert>
|
|
}
|
|
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
|
|
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
|
|
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
|
|
<MudSelectItem T="string" Value="@("")">— none —</MudSelectItem>
|
|
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
|
|
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
|
|
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
|
|
</MudSelect>
|
|
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
|
|
Label="Sub-meter of (upstream meters)" Class="mb-2"
|
|
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
|
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
|
|
@foreach (var m in AvailableUpstream())
|
|
{
|
|
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" />
|
|
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
|
|
<div class="d-flex" style="gap:1rem">
|
|
<MudTextField @bind-Value="_working.Manufacturer" Label="Manufacturer (optional)" Class="mb-2" />
|
|
<MudTextField @bind-Value="_working.Model" Label="Model (optional)" Class="mb-2" />
|
|
</div>
|
|
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="Active" Color="Color.Primary" />
|
|
@if (_working.Id != 0 && _working.RecomputeNeeded)
|
|
{
|
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Mode/baseline changed — consumption will be recomputed on save.</MudAlert>
|
|
}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
|
</DialogActions>
|
|
</MudDialog>
|
|
|
|
@code {
|
|
private List<Meter>? _meters;
|
|
private List<EnergyType> _energyTypes = [];
|
|
private List<MeterLink> _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<Meter> 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<int> Descendants(int meterId)
|
|
{
|
|
var result = new HashSet<int>();
|
|
if (meterId == 0)
|
|
{
|
|
return result;
|
|
}
|
|
|
|
var queue = new Queue<int>();
|
|
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<string> 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();
|
|
}
|
|
|
|
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
|
|
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> 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<int> Upstream { get; set; } = new HashSet<int>();
|
|
|
|
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
|
|
}
|
|
}
|