Analysis: read a rarely-read meter as coarse, not absent; newest rows first
ci / build-test (push) Successful in 2m41s
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.
This commit is contained in:
@@ -233,7 +233,8 @@ else
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
|
||||
// By component, then the newest validity first (A-42): a price history is read from the price that applies now.
|
||||
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenByDescending(t => t.ValidFrom).ToListAsync();
|
||||
_effectiveEnds = TariffValidity.EffectiveEnds(
|
||||
_tariffs.Select(t => new TariffSpan(t.Id, t.ScopeType, t.ScopeId, t.Component, t.ValidFrom, t.ValidTo)));
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
@* The ranges compared; the matched stretch only when less than the whole periods is compared (D-07). *@
|
||||
<ComparisonSummary Period="view.Period" Resolution="view.Data.Comparison"
|
||||
Matched="@(view.Data.CostChange.IsPartial || view.Data.CostChange.Basis == CostChangeBasis.NotComparable ? view.Data.CostChange.Matched : null)"
|
||||
Class="mt-3" />
|
||||
Subject="@S.AnalysisTable_Cost" Class="mt-3" />
|
||||
|
||||
<MudGrid Spacing="3" Class="mt-1">
|
||||
<MudItem xs="12">
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<ComparisonSummary Period="Analysis.Period" Resolution="ComparisonResolution()" Matched="_view.Main?.Comparison?.Matched" Class="mb-3" />
|
||||
<ComparisonSummary Period="Analysis.Period" Resolution="ComparisonResolution()" Matched="_view.Main?.Comparison?.Matched"
|
||||
Subject="@MainName()" Class="mb-3" />
|
||||
<AnalysisChart Buckets="_buckets" Series="_view.Chart" ComparisonPairs="_pairs" Title="@_title" OnBucketClick="_onBucketClick"
|
||||
Resolution="_view.Coarsest" OnUseBucket="UseBucket" />
|
||||
|
||||
@@ -188,6 +189,12 @@
|
||||
private MeterVault.Core.Analysis.Coverage.AvailableRange? Availability() =>
|
||||
_view?.Main?.Availability ?? _quantities?.Availability.Quantity;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the series the comparison line speaks of, when the view charts several whose matched coverage
|
||||
/// may differ (A-41); null when it charts one and the line cannot be misread.
|
||||
/// </summary>
|
||||
private string? MainName() => _view is { Table.Count: > 1 } view ? view.Table[0].Name : null;
|
||||
|
||||
private ComparisonResolution? ComparisonResolution() =>
|
||||
_view?.Metric == AnalysisMetric.Cost ? Analysis.CostComparison?.Resolution : _quantities?.Comparison?.Resolution;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using MeterVault.App.Energy
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Core.Analysis.Coverage
|
||||
@using MeterVault.Core.Analysis.Totals
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@inject NavigationManager Nav
|
||||
@@ -59,7 +60,8 @@ else
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
<ComparisonSummary Period="Analysis.Period" Resolution="_quantities.Comparison?.Resolution" Matched="_main?.Comparison?.Matched" Class="mt-3 mb-4" />
|
||||
<ComparisonSummary Period="Analysis.Period" Resolution="_quantities.Comparison?.Resolution" Matched="_main?.Comparison?.Matched"
|
||||
Subject="@_comparisonSubject" Class="mt-3 mb-4" />
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" md="8">
|
||||
@@ -69,7 +71,7 @@ else
|
||||
<MudLink Href="@HistoryHref(_main is null ? null : AnalysisMetrics.MetricOf(_main.Kind))" Typo="Typo.body2">@S.EnergyView_OpenHistory</MudLink>
|
||||
</div>
|
||||
<AnalysisChart Buckets="_quantities.Plan.Buckets" Series="_trend" ComparisonPairs="_quantities.Comparison?.Buckets"
|
||||
Title="@_trendTitle" Height="240" />
|
||||
Title="@_trendTitle" Height="240" Resolution="_main?.Resolution" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
@@ -102,6 +104,12 @@ else
|
||||
{
|
||||
<div class="mv-cell-secondary">@detail</div>
|
||||
}
|
||||
@* Its own dates whenever they are not the scope's: a measure counted by a tank dipped
|
||||
once a year must not be read against the range of the burner beside it (A-41). *@
|
||||
@if (OwnRange(series) is { } own)
|
||||
{
|
||||
<div class="mv-cell-secondary">@Loc.F(S.Empty_AvailableRange, Format.Date(own.FirstDay), Format.Date(own.LastDay))</div>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
@@ -109,18 +117,23 @@ else
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4">
|
||||
@* A-43: the ranked changes, then every other meter that has data in either period — with its values and
|
||||
the status that says why there is no change. A meter with data is never silently left out. *@
|
||||
<MudText Typo="Typo.h6">@S.EnergyView_LargestChanges</MudText>
|
||||
@if (_quantities.Comparison is not { IsApplicable: true })
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparison</MudText>
|
||||
}
|
||||
else if (_changes.Count == 0)
|
||||
else if (_changes.IsEmpty)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparableMeters</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-2">@S.EnergyView_LargestChangesNote</MudText>
|
||||
@if (_changes.Ranked.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-2">@S.EnergyView_LargestChangesNote</MudText>
|
||||
}
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.EnergyView_LargestChanges" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
|
||||
<thead>
|
||||
@@ -132,17 +145,11 @@ else
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var change in _changes)
|
||||
@foreach (var change in _changes.Ranked)
|
||||
{
|
||||
var series = change.Series;
|
||||
<tr>
|
||||
<th scope="row" class="mv-row-label">
|
||||
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)">@series.Name</MudLink>
|
||||
@if (Analysis.TotalsOf(series.MeterId!.Value) is { IsCounted: false } entry)
|
||||
{
|
||||
<div class="mv-cell-secondary">@entry.Class.Display()</div>
|
||||
}
|
||||
</th>
|
||||
<th scope="row" class="mv-row-label">@MeterCell(series)</th>
|
||||
<td class="mv-num">
|
||||
@Format.Quantity(change.Current, series.Unit)
|
||||
<div class="mv-cell-secondary">@Format.DateRange(change.Matched.Current!.FirstDay, change.Matched.Current.LastDay)</div>
|
||||
@@ -158,8 +165,46 @@ else
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@if (_changes.Rest.Count > 0)
|
||||
{
|
||||
<tbody>
|
||||
@if (_changes.Ranked.Count > 0)
|
||||
{
|
||||
<tr class="mv-row-group">
|
||||
<th scope="colgroup" colspan="4">
|
||||
@S.EnergyView_ChangesRest
|
||||
<div class="mv-cell-secondary">@S.EnergyView_ChangesRestNote</div>
|
||||
</th>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var row in _changes.Rest)
|
||||
{
|
||||
var series = row.Series;
|
||||
<tr>
|
||||
<th scope="row" class="mv-row-label">@MeterCell(series)</th>
|
||||
@FigureCell(row.Current, series)
|
||||
@FigureCell(row.Comparison, series)
|
||||
<td>
|
||||
<ChangeChip Change="row.Change" Polarity="ChangePolarities.For(series.Kind)"
|
||||
FormatMagnitude="@(v => Format.Quantity(v, series.Unit))" />
|
||||
@if (row.SharesNoDays)
|
||||
{
|
||||
<div class="mv-cell-secondary">@S.Comparison_NotComparable</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
}
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
@if (_changes.Hidden > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-2">
|
||||
@Loc.F(S.EnergyView_MoreMeters, _changes.Shown, _changes.Shown + _changes.Hidden)
|
||||
<MudLink Href="@AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabMeters, Query)" Typo="Typo.body2">@S.Nav_Meters</MudLink>
|
||||
</MudText>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
@@ -192,9 +237,10 @@ else
|
||||
private List<AnalysisSeries> _measures = [];
|
||||
private AnalysisSeries? _main;
|
||||
private IReadOnlyList<AnalysisChartSeries> _trend = [];
|
||||
private IReadOnlyList<MeterChange> _changes = [];
|
||||
private MeterChangeList _changes = MeterChangeList.None;
|
||||
private string _trendTitle = string.Empty;
|
||||
private string? _comparisonCaption;
|
||||
private string? _comparisonSubject;
|
||||
private string? _costBasis;
|
||||
private List<string> _costLines = [];
|
||||
private Change? _costChange;
|
||||
@@ -224,6 +270,8 @@ else
|
||||
_noData = _measures.Count > 0 && _measures.All(s => s.Total.Status == BucketStatus.Missing && !s.IsPending);
|
||||
_comparisonCaption = _quantities.Comparison is { IsApplicable: true } ? Query.Comparison.Display() : null;
|
||||
|
||||
_comparisonSubject = _measures.Count > 1 && _main is { } named ? EnergyHistoryView.MeasureName(named, _measures) : null;
|
||||
|
||||
if (_main is { } main)
|
||||
{
|
||||
var name = EnergyHistoryView.MeasureName(main, _measures);
|
||||
@@ -242,10 +290,16 @@ else
|
||||
_trend = [];
|
||||
}
|
||||
|
||||
_changes = MeterChanges.Largest(_quantities.Series, MaxChanges);
|
||||
_changes = MeterChanges.Of(_quantities.Series, MaxChanges);
|
||||
BuildCost();
|
||||
}
|
||||
|
||||
/// <summary>A measure's own availability when it differs from the scope's (A-41); null when they are the same.</summary>
|
||||
private AvailableRange? OwnRange(AnalysisSeries series) =>
|
||||
series.Availability is { } own && (_quantities?.Availability.Quantity is not { } scope || own.FirstDay != scope.FirstDay || own.LastDay != scope.LastDay)
|
||||
? own
|
||||
: null;
|
||||
|
||||
private void BuildCost()
|
||||
{
|
||||
_costBasis = null;
|
||||
@@ -292,6 +346,32 @@ else
|
||||
private Change? ChangeOf(AnalysisSeries series) =>
|
||||
_quantities?.Comparison is { IsApplicable: true } ? series.Comparison?.Change ?? Change.Unavailable : null;
|
||||
|
||||
/// <summary>The meter's name, linked to its own analysis over the same period, and — when it is not counted in the totals — why.</summary>
|
||||
private RenderFragment MeterCell(AnalysisSeries series) => __builder =>
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)">@series.Name</MudLink>
|
||||
@if (Analysis.TotalsOf(series.MeterId!.Value) is { IsCounted: false } entry)
|
||||
{
|
||||
<div class="mv-cell-secondary">@entry.Class.Display()</div>
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A total of a meter that is not ranked: its number, or the status in words instead of a fabricated 0 (§4.3). A
|
||||
/// value that is not plain keeps its status beside it — that is the reason there is no change.
|
||||
/// </summary>
|
||||
private RenderFragment FigureCell(BucketValue value, AnalysisSeries series) => __builder =>
|
||||
{
|
||||
var status = FigureText.Of(value, Analysis.Names.MeterOrNull);
|
||||
<td class="@(status.IsKnown ? "mv-num" : "mv-num mv-unknown")">
|
||||
@(status.IsKnown ? Format.Quantity(value.Value, series.Unit) : Format.Unknown)
|
||||
@if (!status.IsComplete)
|
||||
{
|
||||
<div class="mv-cell-secondary">@status.Summary</div>
|
||||
}
|
||||
</td>
|
||||
};
|
||||
|
||||
private string? MembersCaption(AnalysisSeries series) =>
|
||||
series.MemberIds.Count == 0 ? null : Loc.F(S.EnergyView_CountedMeters, string.Join(", ", series.MemberIds.Select(Analysis.MeterName)));
|
||||
|
||||
|
||||
@@ -102,21 +102,14 @@
|
||||
@if (!View.Markers.IsEmpty)
|
||||
{
|
||||
<MudText Typo="Typo.subtitle1" Class="mt-4 mb-1">@S.MeterDetail_InThisPeriod</MudText>
|
||||
@* One dated list, newest first (A-42): events and price changes interleave, so the dates never jump back up. *@
|
||||
<ul class="mv-markers">
|
||||
@foreach (var e in View.Markers.Events)
|
||||
@foreach (var marker in Markers)
|
||||
{
|
||||
<li>
|
||||
<MudIcon Icon="@MeterEventText.Icon(e.Type)" Size="Size.Small" aria-hidden="true" />
|
||||
<span class="mv-markers__date">@Format.Date(PeriodResolver.LocalDate(e.Time, View.Period.Zone))</span>
|
||||
<span>@e.Type.Display()@EventDetail(e)</span>
|
||||
</li>
|
||||
}
|
||||
@foreach (var t in View.Markers.TariffChanges)
|
||||
{
|
||||
<li>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Sell" Size="Size.Small" aria-hidden="true" />
|
||||
<span class="mv-markers__date">@Format.Date(t.ValidFrom)</span>
|
||||
<span>@Loc.F(S.MeterDetail_TariffChange, t.Component.Display(), Format.Number(t.Value, 4), t.Unit, ScopeText(t))</span>
|
||||
<MudIcon Icon="@marker.Icon" Size="Size.Small" aria-hidden="true" />
|
||||
<span class="mv-markers__date">@Format.Date(marker.Day)</span>
|
||||
<span>@marker.Text</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -157,6 +150,9 @@
|
||||
|
||||
private AnalysisSeries? Series => View.Series;
|
||||
|
||||
/// <summary>The events and tariff changes of the range as one dated list, newest first (A-42).</summary>
|
||||
private IReadOnlyList<MeterMarker> Markers => MeterMarkerList.Of(View.Markers, View.Period.Zone, Detail.EnergyType);
|
||||
|
||||
/// <summary>A first reading booked against the baseline with an unknown start (D-14) sits in the range.</summary>
|
||||
private bool HasOpeningBalance =>
|
||||
Series is { } s && (s.Total.Provenance.HasFlag(Provenance.OpeningBalance) || s.Values.Any(v => v.Provenance.HasFlag(Provenance.OpeningBalance)));
|
||||
@@ -214,19 +210,4 @@
|
||||
return MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, target);
|
||||
}
|
||||
|
||||
private static string EventDetail(EventRow e) => e.Type switch
|
||||
{
|
||||
MeterEventType.Delivery or MeterEventType.TankLevel when e.Amount is { } amount => $": {Format.Number(amount, 1)} {e.Unit}".TrimEnd(),
|
||||
MeterEventType.MeterSwap or MeterEventType.CounterReset when e.PrevValue is not null || e.NewValue is not null =>
|
||||
$": {(e.PrevValue is { } p ? Format.Number(p, 2) : Format.Unknown)} → {(e.NewValue is { } n ? Format.Number(n, 2) : Format.Unknown)}",
|
||||
_ when !string.IsNullOrWhiteSpace(e.Notes) => ": " + e.Notes,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private string ScopeText(TariffRow tariff) => tariff.Scope switch
|
||||
{
|
||||
TariffScope.Meter => S.MeterDetail_ScopeThisMeter,
|
||||
TariffScope.EnergyType => Detail.EnergyType,
|
||||
_ => tariff.Scope.Display(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
private int _stale;
|
||||
|
||||
/// <summary>
|
||||
/// The period as a whole: complete when every measure is, no data when none has any, being prepared while a rebuild
|
||||
/// runs, partial otherwise. Without any meter, the bill's own availability (manual costs are always available).
|
||||
/// The period as a whole: complete when every measure is, no data when none has any, only coarser data when every
|
||||
/// measure has only that (A-41), being prepared while a rebuild runs, partial otherwise. Without any meter, the
|
||||
/// bill's own availability (manual costs are always available).
|
||||
/// </summary>
|
||||
private BucketStatus PeriodStatus
|
||||
{
|
||||
@@ -68,6 +69,7 @@
|
||||
|
||||
return statuses.All(s => s == BucketStatus.Available) ? BucketStatus.Available
|
||||
: statuses.All(s => s == BucketStatus.Missing) ? BucketStatus.Missing
|
||||
: statuses.All(s => s == BucketStatus.Unresolved) ? BucketStatus.Unresolved
|
||||
: BucketStatus.Partial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</MudGrid>
|
||||
|
||||
<ComparisonSummary Period="Site.Quantities.Period" Resolution="Site.Quantities.Comparison?.Resolution"
|
||||
Matched="Site.Generation?.Comparison?.Matched" Class="mb-3" />
|
||||
Matched="Site.Generation?.Comparison?.Matched" Subject="@S.Solar_Generation" Class="mb-3" />
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mb-4">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationAndSelfConsumption</MudText>
|
||||
|
||||
Reference in New Issue
Block a user