Analysis: read a rarely-read meter as coarse, not absent; newest rows first
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:
Florian Schmidt
2026-09-20 12:44:32 +02:00
parent 5a6f34a467
commit a08e9f781f
38 changed files with 1301 additions and 184 deletions
+2 -1
View File
@@ -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();
+1 -1
View File
@@ -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>