Buckets { get; set; } = [];
diff --git a/src/App/Components/Shared/Analysis/ComparisonSummary.razor b/src/App/Components/Shared/Analysis/ComparisonSummary.razor
index 44b4181..75da81f 100644
--- a/src/App/Components/Shared/Analysis/ComparisonSummary.razor
+++ b/src/App/Components/Shared/Analysis/ComparisonSummary.razor
@@ -1,6 +1,10 @@
@* Which dates are compared (D-06, D-07, brief §4.2): the current period and the comparison as requested, and the
stretch both actually cover, over which a change is stated. When nothing matches, the comparison is "not
- comparable": absolute values only, no percentage. A comparison that does not apply says why. *@
+ comparable": absolute values only, no percentage. A comparison that does not apply says why.
+
+ Matched coverage belongs to one figure, not to the page: a tank dipped once a year shares no day with the year
+ before while the burner beside it compares month by month. A page that shows several figures therefore names the
+ one this line is about (Subject, A-41), so the line never contradicts a card or a table below it. *@
@if (Resolution is { } resolution)
{
@@ -14,13 +18,14 @@
{
- @S.Comparison_NotComparable
+ @(Named is { } subject ? Loc.F(S.Comparison_NotComparableFor, subject) : S.Comparison_NotComparable)
}
else if (matched.Current is { } current && matched.Comparison is { } other)
{
+ var (from, to) = (Format.DateRange(current.FirstDay, current.LastDay), Format.DateRange(other.FirstDay, other.LastDay));
- @Loc.F(S.Comparison_Matched, Format.DateRange(current.FirstDay, current.LastDay), Format.DateRange(other.FirstDay, other.LastDay))@(matched.IsContiguous ? string.Empty : " " + S.Comparison_WithGaps)
+ @(Named is { } subject ? Loc.F(S.Comparison_MatchedFor, subject, from, to) : Loc.F(S.Comparison_Matched, from, to))@(matched.IsContiguous ? string.Empty : " " + S.Comparison_WithGaps)
}
}
@@ -45,8 +50,17 @@
[Parameter]
public MeterVault.Core.Analysis.Coverage.MatchedCoverageResult? Matched { get; set; }
+ ///
+ /// The figure this line is about (a measure's or a meter's name, "Cost"), when the page shows several whose
+ /// matched coverage may differ; null when there is only one and the line cannot be misread.
+ ///
+ [Parameter]
+ public string? Subject { get; set; }
+
[Parameter]
public string? Class { get; set; }
private string CurrentRange => Format.PeriodRange(Period);
+
+ private string? Named => string.IsNullOrWhiteSpace(Subject) ? null : Subject.Trim();
}
diff --git a/src/App/Components/Shared/MeterEditing/VirtualCalculationPreview.razor b/src/App/Components/Shared/MeterEditing/VirtualCalculationPreview.razor
index aaf82dd..cce675d 100644
--- a/src/App/Components/Shared/MeterEditing/VirtualCalculationPreview.razor
+++ b/src/App/Components/Shared/MeterEditing/VirtualCalculationPreview.razor
@@ -58,19 +58,8 @@
}
+ @* Newest month first, the total above them (A-42), as every dated table here reads. *@
- @for (var i = 0; i < preview.Buckets.Count; i++)
- {
- var index = i;
-
- | @Format.BucketLabel(preview.Buckets[index], preview.SpansYears) |
- @Cell(series.Values[index], series.Unit, strong: true)
- @foreach (var source in preview.Sources)
- {
- @Cell(index < source.Values.Count ? source.Values[index] : null, Model.Option(source.MeterId)?.Unit ?? series.Unit, strong: false)
- }
-
- }
| @S.MeterEditor_PreviewTotalRow |
@Cell(series.Total, series.Unit, strong: true)
@@ -79,6 +68,18 @@
@Cell(source.Total, Model.Option(source.MeterId)?.Unit ?? series.Unit, strong: false)
}
+ @for (var i = preview.Buckets.Count - 1; i >= 0; i--)
+ {
+ var index = i;
+
+ | @Format.BucketLabel(preview.Buckets[index], preview.SpansYears) |
+ @Cell(index < series.Values.Count ? series.Values[index] : null, series.Unit, strong: true)
+ @foreach (var source in preview.Sources)
+ {
+ @Cell(index < source.Values.Count ? source.Values[index] : null, Model.Option(source.MeterId)?.Unit ?? series.Unit, strong: false)
+ }
+
+ }
@@ -212,8 +213,10 @@
List sources = [.. series.Contributions.Select(c => new SourceColumn(c.MeterId, c.Values, c.Total))];
List chart = [AnalysisChartSeries.ForSeries(series, series.Name)];
var spansYears = Format.SpansYears(buckets);
+
+ // Newest first (A-42): the list is capped, and the months a reader is about to look at are the recent ones.
List warnings = [];
- for (var i = 0; i < buckets.Count && i < series.Values.Count; i++)
+ for (var i = Math.Min(buckets.Count, series.Values.Count) - 1; i >= 0; i--)
{
var status = FigureText.Of(series.Values[i], model.Name);
if (!status.IsComplete)
diff --git a/src/App/Energy/MeterChanges.cs b/src/App/Energy/MeterChanges.cs
index 5d66c2e..4b2257c 100644
--- a/src/App/Energy/MeterChanges.cs
+++ b/src/App/Energy/MeterChanges.cs
@@ -15,6 +15,38 @@ namespace MeterVault.App.Energy;
/// The matched ranges.
public sealed record MeterChange(AnalysisSeries Series, double Current, double Previous, Change Change, MatchedCoverageResult Matched);
+///
+/// A meter listed beside the ranking (A-43): it has data in one of the two periods but is not ranked — because nothing
+/// can be measured over dates both periods cover, because one side has no data or only coarser data, or because it is
+/// an indicator, which is never ranked against quantities. Its totals are the ones the reader read, so the page can
+/// word each of them with the same status text every other figure gets (D-14).
+///
+/// The meter's series.
+/// Its total over the requested period, with its status.
+/// Its total over the comparison period, with its status; missing when none was read.
+/// Its own change when it has one (an indicator, a meter past the ranking's cap); unavailable otherwise.
+/// Both totals are known, yet the two periods share no covered day (D-07).
+public sealed record MeterChangeRow(
+ AnalysisSeries Series, BucketValue Current, BucketValue Comparison, Change Change, bool SharesNoDays);
+
+///
+/// The rows of "largest changes by meter" (brief §7.3, A-43): the ranked changes, the meters with data but no ranked
+/// change, and how many meters the caps left out.
+///
+/// The comparable meters, largest change first.
+/// The remaining meters with data in either period, by name.
+/// Meters with data that neither list has room for; 0 when everything is shown.
+public sealed record MeterChangeList(IReadOnlyList Ranked, IReadOnlyList Rest, int Hidden)
+{
+ public static MeterChangeList None { get; } = new([], [], 0);
+
+ /// How many meters the panel lists.
+ public int Shown => Ranked.Count + Rest.Count;
+
+ /// True when no meter of the scope has data in either period.
+ public bool IsEmpty => Shown == 0 && Hidden == 0;
+}
+
/// The Overview tab's "largest changes by meter" (brief §7.3).
public static class MeterChanges
{
@@ -32,10 +64,7 @@ public static class MeterChanges
var changes = series
.Where(s => s.MeterId is not null && s.Kind != QuantityKind.Indicator)
- .Select(s => s.Comparison is { Matched.IsComparable: true, CurrentMatched: { } current, ComparisonMatched: { } previous } comparison
- && comparison.Change is { IsAvailable: true } change
- ? new MeterChange(s, current, previous, change, comparison.Matched)
- : null)
+ .Select(ChangeOf)
.OfType()
.ToList();
@@ -46,4 +75,69 @@ public static class MeterChanges
return [.. ordered.ThenBy(c => c.Series.Name, StringComparer.CurrentCultureIgnoreCase).Take(max)];
}
+
+ ///
+ /// Every meter the panel has something to say about (A-43): the ranked changes first, then the meters that have
+ /// data in this period or in the comparison but no ranked change — with their totals and their status, never
+ /// dropped. A meter with no data on either side is not listed: there is nothing to say about it here.
+ ///
+ ///
+ /// The reason a row is not ranked is never invented here: it is the status of the two totals (no data, only
+ /// coarser data, being prepared) plus for the one case both figures are
+ /// known and still nothing can be compared (D-07). Each list is capped on its own, so a long ranking can never
+ /// crowd out the meters that have no change — what neither holds is counted in
+ /// and said out loud.
+ ///
+ /// The meters' series () of a read with a comparison.
+ /// How many rows each of the two lists holds at most.
+ public static MeterChangeList Of(IEnumerable series, int max)
+ {
+ ArgumentNullException.ThrowIfNull(series);
+ ArgumentOutOfRangeException.ThrowIfNegative(max);
+
+ var meters = series.Where(s => s.MeterId is not null).ToList();
+ var ranked = Largest(meters, max);
+ var rankedKeys = ranked.Select(c => c.Series.Key).ToHashSet();
+
+ // Those with no change at all come first: they are the ones the ranking cannot show, and the reason this list
+ // exists. Meters past the ranking's cap follow — they are only ranked further down, not unexplained.
+ List rest =
+ [
+ .. meters
+ .Where(s => !rankedKeys.Contains(s.Key) && HasData(s))
+ .Select(Row)
+ .OrderBy(r => r.Change.IsAvailable)
+ .ThenBy(r => r.Series.Name, StringComparer.CurrentCultureIgnoreCase)
+ .ThenBy(r => r.Series.MeterId!.Value),
+ ];
+
+ var hidden = Math.Max(0, rest.Count - max);
+ return new MeterChangeList(ranked, hidden > 0 ? rest[..max] : rest, hidden);
+ }
+
+ /// The meter's comparable change, or null when nothing can be measured over dates both periods cover.
+ private static MeterChange? ChangeOf(AnalysisSeries series) =>
+ series.Comparison is { Matched.IsComparable: true, CurrentMatched: { } current, ComparisonMatched: { } previous } comparison
+ && comparison.Change is { IsAvailable: true } change
+ ? new MeterChange(series, current, previous, change, comparison.Matched)
+ : null;
+
+ ///
+ /// True when the reader has something other than "no data" to say about the meter on at least one side — a value,
+ /// a partial or coarser figure, a rebuild in progress, a failed calculation. A meter missing on both sides is left
+ /// out: it would only repeat "no data" twice.
+ ///
+ private static bool HasData(AnalysisSeries series) =>
+ series.Total.Status != BucketStatus.Missing
+ || (series.Comparison is { } comparison && comparison.Total.Status != BucketStatus.Missing);
+
+ private static MeterChangeRow Row(AnalysisSeries series)
+ {
+ var comparison = series.Comparison;
+ var previous = comparison?.Total ?? BucketValue.Missing();
+ var change = comparison?.Change ?? Change.Unavailable;
+ var sharesNoDays = comparison is { Matched.IsComparable: false }
+ && series.Total.Value is not null && previous.Value is not null;
+ return new MeterChangeRow(series, series.Total, previous, change, sharesNoDays);
+ }
}
diff --git a/src/App/Localization/Strings.de.resx b/src/App/Localization/Strings.de.resx
index b57fdeb..2693f96 100644
--- a/src/App/Localization/Strings.de.resx
+++ b/src/App/Localization/Strings.de.resx
@@ -438,9 +438,15 @@
Veränderung über den gemeinsam abgedeckten Zeitraum: {0} und {1}.
+
+ {0}: Veränderung über den gemeinsam abgedeckten Zeitraum – {1} und {2}.
+
Nicht vergleichbar: Die Zeiträume haben keine gemeinsam abgedeckten Tage, daher werden nur die Werte gezeigt.
+
+ {0}: Nicht vergleichbar – die Zeiträume haben keine gemeinsam abgedeckten Tage, daher werden nur die Werte gezeigt.
+
{0} im Vergleich zu {1}
@@ -897,6 +903,12 @@
Verbinden
+
+ Weitere Zähler
+
+
+ Zähler mit Daten, die in der Rangfolge nicht vorkommen — mit ihren Werten und ihrem Status.
+
{0} — {1}
@@ -1015,7 +1027,7 @@
Größte Veränderungen je Zähler
- Jede Veränderung wird über die Tage gemessen, die beide Zeiträume abdecken.
+ Eine Veränderung wird über die Tage gemessen, die beide Zeiträume abdecken.
Legende
@@ -1051,7 +1063,7 @@
{0} von {1} Zählern angezeigt. Die übrigen vergleichen Sie unter:
- Kein Zähler hat in beiden Zeiträumen Daten über dieselben Tage.
+ Kein Zähler dieser Energieart hat Daten in diesem Zeitraum oder im Vergleich.
Wählen Sie einen Vergleich, um zu sehen, welche Zähler sich am stärksten verändert haben.
diff --git a/src/App/Localization/Strings.resx b/src/App/Localization/Strings.resx
index 3e53834..738b184 100644
--- a/src/App/Localization/Strings.resx
+++ b/src/App/Localization/Strings.resx
@@ -438,9 +438,15 @@
Change measured over what both cover: {0} and {1}.
+
+ {0}: change measured over what both cover — {1} and {2}.
+
Not comparable: the two periods share no covered days, so only the values are shown.
+
+ {0}: not comparable — the two periods share no covered days, so only the values are shown.
+
{0} compared with {1}
@@ -897,6 +903,12 @@
Connect
+
+ Other meters
+
+
+ Meters with data that the ranking does not cover, with their values and their status.
+
{0} — {1}
@@ -1015,7 +1027,7 @@
Largest changes by meter
- Each change is measured over the dates both periods cover.
+ A change is measured over the dates both periods cover.
Legend
@@ -1051,7 +1063,7 @@
Showing {0} of {1} meters. Compare the others on:
- No meter has data over matching dates in both periods.
+ No meter of this energy type has data in this period or in the comparison.
Choose a comparison to see which meters changed most.
diff --git a/src/App/MeterDetails/MeterMarkerList.cs b/src/App/MeterDetails/MeterMarkerList.cs
new file mode 100644
index 0000000..f4dc57a
--- /dev/null
+++ b/src/App/MeterDetails/MeterMarkerList.cs
@@ -0,0 +1,66 @@
+using MeterVault.App.Localization;
+using MeterVault.Core.Analysis;
+using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Dashboard;
+using MudBlazor;
+
+namespace MeterVault.App.MeterDetails;
+
+/// One dated marker beside the meter's chart: a lifecycle event or a price change.
+/// The local day it falls on.
+/// Its icon.
+/// What it says, in the reader's language; a note and a meter's name stay as entered.
+public sealed record MeterMarker(DateOnly Day, string Icon, string Text);
+
+///
+/// The contextual markers of the meter page (brief §7.2) as one list: the events and the tariff changes of the range
+/// interleaved by their local day, newest first (A-42). Two lists one after the other would make the dates run down,
+/// jump back up and run down again; one list reads as the period does.
+///
+public static class MeterMarkerList
+{
+ ///
+ /// The markers of , newest first; on one day an event comes before the price that
+ /// starts with it, because the event is what happened and the price is what it is billed at afterwards.
+ ///
+ /// The events and tariff changes the reader read ().
+ /// The instance zone an event's instant is dated in.
+ /// The meter's energy type (user data), for a tariff scoped to it.
+ public static IReadOnlyList Of(MeterMarkers markers, TimeZoneInfo zone, string energyTypeName)
+ {
+ ArgumentNullException.ThrowIfNull(markers);
+ ArgumentNullException.ThrowIfNull(zone);
+
+ return
+ [
+ .. markers.Events
+ .Select(e => (Marker: new MeterMarker(
+ PeriodResolver.LocalDate(e.Time, zone), MeterEventText.Icon(e.Type), e.Type.Display() + Detail(e)), Order: 0))
+ .Concat(markers.TariffChanges.Select(t => (Marker: new MeterMarker(
+ t.ValidFrom,
+ Icons.Material.Filled.Sell,
+ Loc.F(Strings.MeterDetail_TariffChange, t.Component.Display(), Format.Number(t.Value, 4), t.Unit, ScopeText(t, energyTypeName))), Order: 1)))
+ .OrderByDescending(m => m.Marker.Day)
+ .ThenBy(m => m.Order)
+ .Select(m => m.Marker),
+ ];
+ }
+
+ /// What an event booked: an amount, a register boundary, or its note.
+ private static string Detail(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,
+ };
+
+ /// Whose price it is: this meter's, its energy type's (named) or the global one.
+ private static string ScopeText(TariffRow tariff, string energyTypeName) => tariff.Scope switch
+ {
+ TariffScope.Meter => Strings.MeterDetail_ScopeThisMeter,
+ TariffScope.EnergyType => energyTypeName,
+ _ => tariff.Scope.Display(),
+ };
+}
diff --git a/src/App/wwwroot/app.css b/src/App/wwwroot/app.css
index 127ca9e..7c195c6 100644
--- a/src/App/wwwroot/app.css
+++ b/src/App/wwwroot/app.css
@@ -99,7 +99,8 @@ h1:focus {
.mv-num { text-align: right; font-variant-numeric: tabular-nums; }
.mv-cell-secondary { font-size: 0.75rem; color: var(--mud-palette-text-secondary); }
.mv-row-label { font-weight: 400; text-align: left; }
-.mv-row-total th, .mv-row-total td { font-weight: 600; border-top: 2px solid var(--mud-palette-lines-default); }
+.mv-row-group th { text-align: left; font-weight: 600; padding-top: 12px; border-top: 1px solid var(--mud-palette-lines-default); white-space: normal; }
+.mv-row-total th, .mv-row-total td { font-weight: 600; border-bottom: 2px solid var(--mud-palette-lines-default); }
.mv-qualified { font-style: italic; }
.mv-unknown { color: var(--mud-palette-text-secondary); }
.mv-drill { width: 1%; }
diff --git a/src/Core/Analysis/Coverage/CoverageEvaluator.cs b/src/Core/Analysis/Coverage/CoverageEvaluator.cs
index b01ee0d..767e773 100644
--- a/src/Core/Analysis/Coverage/CoverageEvaluator.cs
+++ b/src/Core/Analysis/Coverage/CoverageEvaluator.cs
@@ -12,8 +12,10 @@ namespace MeterVault.Core.Analysis.Coverage;
/// -
/// Missing when no non-gap run overlaps the bucket. The issue is the reason of a gap run overlapping
/// it ( for an unexplained decrease or a reset without a previous
-/// value, ), otherwise . Exception: a
-/// bucket holding an opening balance is partial, never missing, because a value is booked in it.
+/// value, ), otherwise . Two exceptions: a
+/// bucket holding an opening balance is partial, never missing, because a value is booked in it; and a bucket
+/// waiting for a measurement coarser than itself is unresolved, never missing (A-41, see
+/// AwaitsMeasurement) — a dipstick read twice a year has not yet booked the months since the last one.
///
/// -
/// Unresolved () when any overlapping non-gap run fails the
@@ -106,7 +108,7 @@ public static class CoverageEvaluator
var usable = Usable(runs, now, zone);
var covering = usable.Where(r => !r.IsGap && Overlaps(r, bucket)).ToList();
var gaps = usable.Where(r => r.IsGap && Overlaps(r, bucket)).ToList();
- return EvaluateCore(bucket, covering, gaps, zone, checkResolution: true, openingBalanceInBucket, now);
+ return EvaluateCore(bucket, covering, gaps, zone, checkResolution: true, openingBalanceInBucket, now, Preceding(usable, bucket));
}
///
@@ -138,8 +140,8 @@ public static class CoverageEvaluator
var results = new BucketCoverage[buckets.Count];
foreach (var i in order)
{
- var (covering, gaps) = sweep.Overlapping(buckets[i]);
- results[i] = EvaluateCore(buckets[i], covering, gaps, zone, checkResolution: true, openingBalanceInBucket?[i] ?? false, now);
+ var (covering, gaps, preceding) = sweep.Overlapping(buckets[i]);
+ results[i] = EvaluateCore(buckets[i], covering, gaps, zone, checkResolution: true, openingBalanceInBucket?[i] ?? false, now, preceding);
}
return results;
@@ -192,7 +194,8 @@ public static class CoverageEvaluator
zone,
checkResolution: true,
openingBalance: false,
- now))
+ now,
+ Preceding(runs, bucket)))
.ToList();
return Combine(jointCoverage, sourceCoverage);
@@ -227,11 +230,11 @@ public static class CoverageEvaluator
foreach (var i in order)
{
var bucket = buckets[i];
- var (jointCovering, jointGaps) = jointSweep.Overlapping(bucket);
+ var (jointCovering, jointGaps, _) = jointSweep.Overlapping(bucket);
var joint = EvaluateCore(bucket, jointCovering, jointGaps, zone, checkResolution: false, openingBalanceInBucket?[i] ?? false, now);
var sourceCoverage = sourceSweeps
.Select(sweep => sweep.Overlapping(bucket))
- .Select(o => EvaluateCore(bucket, o.Covering, o.Gaps, zone, checkResolution: true, openingBalance: false, now))
+ .Select(o => EvaluateCore(bucket, o.Covering, o.Gaps, zone, checkResolution: true, openingBalance: false, now, o.Preceding))
.ToList();
results[i] = Combine(joint, sourceCoverage);
}
@@ -250,16 +253,10 @@ public static class CoverageEvaluator
ArgumentNullException.ThrowIfNull(zone);
// The class rule alone settles day and week buckets, and month and year buckets for day-or-finer data
- // (an interval of at most 25 hours crosses a month edge by less than the tolerance). An undivided week-
- // or month-class interval across a month edge is its own run (CoverageBuilder), so the edge tolerance
- // below judges it exactly (D-14).
- if (run.Resolution <= ResolutionClassifier.CoarsestResolving(bucket.Size)
- && (bucket.Size is BucketSize.Day or BucketSize.Week || run.Resolution <= ResolutionClass.Day))
- {
- return true;
- }
-
- if (run.DividedAtMonths && bucket.Size is BucketSize.Month or BucketSize.Year)
+ // (an interval of at most 25 hours crosses a month edge by less than the tolerance) and for runs divided
+ // at the month edges. An undivided week- or month-class interval across a month edge is its own run
+ // (CoverageBuilder), so the edge tolerance below judges it exactly (D-14).
+ if (FitsBucketSize(run, bucket))
{
return true;
}
@@ -294,6 +291,53 @@ public static class CoverageEvaluator
return true;
}
+ ///
+ /// Whether a run's own rhythm places its amounts inside buckets of this size, without looking at where the
+ /// run lies: day-or-finer data for any size, week data for week buckets, and data divided at the local month
+ /// edges for month and year buckets (A-02, A-03). The first two tests of .
+ ///
+ private static bool FitsBucketSize(CoverageRun run, AnalysisBucket bucket) =>
+ (run.Resolution <= ResolutionClassifier.CoarsestResolving(bucket.Size)
+ && (bucket.Size is BucketSize.Day or BucketSize.Week || run.Resolution <= ResolutionClass.Day))
+ || (run.DividedAtMonths && bucket.Size is BucketSize.Month or BucketSize.Year);
+
+ ///
+ /// Whether a bucket that no run covers is still waiting to be measured rather than without data (A-41).
+ ///
+ ///
+ /// A tank read by dipstick twice a year, or a burner whose hours are noted every few months, books nothing
+ /// between two readings: the amount of the stretch is only known once the next reading closes it. A period
+ /// that opened after the last reading therefore has no coverage at all, although the meter is neither silent
+ /// nor broken — its measurement is simply coarser than the question. Saying "no data" there contradicts the
+ /// coverage the same page reports, so such a bucket reads "only coarser data" (D-14), with the resolution of
+ /// the data before it and — like every unresolved bucket — without a number.
+ ///
+ /// It holds only for data that cannot place an amount in a bucket of this size anyway
+ /// (): an hourly meter that went silent, or a monthly sheet whose months are
+ /// divided, books its buckets as it goes, so a bucket it does not cover really has none. And it holds only
+ /// within one interval of the run's class past the run (, A-04) —
+ /// unbounded for data coarser than a month, whose next reading may be a year away, but not for a monthly
+ /// import asked about a day two years after it ended.
+ ///
+ ///
+ private static bool AwaitsMeasurement(CoverageRun run, AnalysisBucket bucket) =>
+ !FitsBucketSize(run, bucket) && bucket.To - run.To <= ResolutionClassifier.LimitOf(run.Resolution);
+
+ /// The meter's last covered stretch before , or null when it has none.
+ private static CoverageRun? Preceding(IReadOnlyList runs, AnalysisBucket bucket)
+ {
+ CoverageRun? best = null;
+ foreach (var run in runs)
+ {
+ if (!run.IsGap && run.To <= bucket.From && (best is null || run.To > best.To))
+ {
+ best = run;
+ }
+ }
+
+ return best;
+ }
+
private static BucketCoverage EvaluateCore(
AnalysisBucket bucket,
List covering,
@@ -301,7 +345,8 @@ public static class CoverageEvaluator
TimeZoneInfo zone,
bool checkResolution,
bool openingBalance,
- DateTimeOffset? now)
+ DateTimeOffset? now,
+ CoverageRun? preceding = null)
{
var length = bucket.To - bucket.From;
if (length == TimeSpan.Zero)
@@ -314,13 +359,19 @@ public static class CoverageEvaluator
var covered = stretches.Aggregate(TimeSpan.Zero, (sum, s) => sum + s.Length);
var reasons = gaps.Select(g => g.Gap).Distinct().OrderBy(Significance).ToList();
- ResolutionClass? resolution = covering.Count > 0 ? covering.Max(r => r.Resolution) : null;
+ // A bucket nothing covers is not always without data: a meter read every few months has not measured it
+ // *yet* (A-41). Then its status is the coarse resolution of the data around it, not "no data".
+ var awaited = covering.Count == 0 && checkResolution && reasons.Count == 0 && !openingBalance
+ && preceding is { } last && AwaitsMeasurement(last, bucket) ? last.Resolution : (ResolutionClass?)null;
+
+ ResolutionClass? resolution = covering.Count > 0 ? covering.Max(r => r.Resolution) : awaited;
var unresolved = checkResolution && covering.Exists(r => !Resolves(r, bucket, zone));
var complete = covered >= length - CoverageTolerance || UpToDate(bucket, covering, gaps, stretches, covered, now);
var (status, issue) = (covering.Count, unresolved, openingBalance) switch
{
(0, _, true) => (BucketStatus.Partial, ValueIssue.OpeningBalance),
+ (0, _, false) when awaited is not null => (BucketStatus.Unresolved, ValueIssue.CoarseResolution),
(0, _, false) => (BucketStatus.Missing, reasons.Count > 0 ? IssueOf(reasons[0]) : ValueIssue.NoCoverage),
(_, true, _) => (BucketStatus.Unresolved, ValueIssue.CoarseResolution),
(_, false, true) => (BucketStatus.Partial, ValueIssue.OpeningBalance),
@@ -463,16 +514,35 @@ public static class CoverageEvaluator
private int _next;
- /// The covering and gap runs overlapping ; buckets must come by ascending start.
- public (List Covering, List Gaps) Overlapping(AnalysisBucket bucket)
+ private CoverageRun? _preceding;
+
+ ///
+ /// The covering and gap runs overlapping , and the last covered stretch before it
+ /// (A-41); buckets must come by ascending start.
+ ///
+ public (List Covering, List Gaps, CoverageRun? Preceding) Overlapping(AnalysisBucket bucket)
{
while (_next < _runs.Count && _runs[_next].From < bucket.To)
{
_active.Add(_runs[_next++]);
}
- // No later bucket starts before this one, so a run ending by its start is done for good.
- _active.RemoveAll(r => r.To <= bucket.From);
+ // No later bucket starts before this one, so a run ending by its start is done for good — but it is
+ // still the coverage before every later bucket.
+ _active.RemoveAll(r =>
+ {
+ if (r.To > bucket.From)
+ {
+ return false;
+ }
+
+ if (!r.IsGap && (_preceding is null || r.To > _preceding.To))
+ {
+ _preceding = r;
+ }
+
+ return true;
+ });
var covering = new List();
var gaps = new List();
@@ -484,7 +554,7 @@ public static class CoverageEvaluator
}
}
- return (covering, gaps);
+ return (covering, gaps, _preceding);
}
}
}
diff --git a/src/Core/Analysis/Coverage/ResolutionClassifier.cs b/src/Core/Analysis/Coverage/ResolutionClassifier.cs
index 6c1e85e..09e4115 100644
--- a/src/Core/Analysis/Coverage/ResolutionClassifier.cs
+++ b/src/Core/Analysis/Coverage/ResolutionClassifier.cs
@@ -83,6 +83,55 @@ public static class ResolutionClassifier
_ => throw new ArgumentOutOfRangeException(nameof(size), size, "Only concrete bucket sizes have a resolution class."),
};
+ ///
+ /// The class the data a chart plots asks to floor at (A-41): the coarsest
+ /// among , with two corrections — a run divided at the local month edges counts as
+ /// whatever its length (A-03), and data coarser than a month asks for
+ /// years only when years can place every bit of it. Null when nothing covers the range.
+ ///
+ ///
+ /// only says "longer than a local month". A meter read once a quarter, on
+ /// the quarter, books each interval inside one year: a chart of years holds every one of them whole and is
+ /// exactly the right answer. A tank dipped every autumn straddles a New Year as surely as it straddles every
+ /// month start, so year buckets leave it just as unresolved as month buckets do ()
+ /// — while answering "last 12 months" with a single bar. Where one such interval is in the range, years earn
+ /// nothing, so the floor stops at the month and the unresolved buckets say why, with the toolbar and the empty
+ /// chart offering the coarser interval (D-14, A-28). It is one decision for the whole chart, because a chart
+ /// has one bucket size (D-05).
+ ///
+ /// The covering runs of everything plotted, over the range plotted; gap runs are ignored.
+ /// The instance zone, whose local years the runs are measured against.
+ public static ResolutionClass? PlanningResolution(IEnumerable runs, TimeZoneInfo zone)
+ {
+ ArgumentNullException.ThrowIfNull(runs);
+ ArgumentNullException.ThrowIfNull(zone);
+
+ ResolutionClass? coarsest = null;
+ var yearsPlaceEverything = true;
+ foreach (var run in runs)
+ {
+ if (run.IsGap)
+ {
+ continue;
+ }
+
+ var resolution = run.DividedAtMonths && run.Resolution > ResolutionClass.Month ? ResolutionClass.Month : run.Resolution;
+ if (resolution == ResolutionClass.Coarse && !WithinOneLocalYear(run, zone))
+ {
+ yearsPlaceEverything = false;
+ }
+
+ coarsest = coarsest is { } c && c >= resolution ? c : resolution;
+ }
+
+ return coarsest == ResolutionClass.Coarse && !yearsPlaceEverything ? ResolutionClass.Month : coarsest;
+ }
+
+ /// True when [From, To) lies inside a single local calendar year; an empty run trivially does.
+ private static bool WithinOneLocalYear(CoverageRun run, TimeZoneInfo zone) =>
+ run.To <= run.From
+ || CalendarEdges.LocalDate(run.From, zone).Year == CalendarEdges.LocalDate(run.To.AddTicks(-1), zone).Year;
+
/// The coarser of two classes — the resolution of values that combine both (D-27).
public static ResolutionClass Coarsest(ResolutionClass a, ResolutionClass b) => a >= b ? a : b;
}
diff --git a/src/Core/Analysis/Time/BucketPlanner.cs b/src/Core/Analysis/Time/BucketPlanner.cs
index 810344c..ff47d3d 100644
--- a/src/Core/Analysis/Time/BucketPlanner.cs
+++ b/src/Core/Analysis/Time/BucketPlanner.cs
@@ -11,8 +11,10 @@ namespace MeterVault.Core.Analysis;
/// A-06) — day up to 62 days, week up to 26 weeks, otherwise month — so "year to date" charts by month in
/// February as in November, and one URL renders the same way all year. It never goes finer than the
/// coarsest resolution any plotted series can resolve (coarsestNeeded): a monthly import asked by
-/// day would only produce "unresolved" buckets. It then coarsens until the buckets that actually exist — up
-/// to now — fit the caller's point limit, so centuries of history end up in years.
+/// day would only produce "unresolved" buckets. What a plotted run asks for is
+/// , which keeps data no bucket size can place
+/// from coarsening the whole chart (A-41). It then coarsens until the buckets that actually exist — up to now —
+/// fit the caller's point limit, so centuries of history end up in years.
///
///
/// An explicit size is honoured, even finer than the data (those buckets read as unresolved, D-14), but
diff --git a/src/Infrastructure/Analysis/AnalysisRun.cs b/src/Infrastructure/Analysis/AnalysisRun.cs
index 45761ed..c288eb2 100644
--- a/src/Infrastructure/Analysis/AnalysisRun.cs
+++ b/src/Infrastructure/Analysis/AnalysisRun.cs
@@ -302,28 +302,17 @@ internal sealed class AnalysisRun
}
///
- /// The coarsest resolution among the data covering the period (D-05): auto never plans finer. A run divided at
- /// month boundaries resolves months whatever its class.
+ /// The coarsest resolution among the data covering the period (D-05): auto never plans finer. What each run asks
+ /// for is — a run divided at month boundaries resolves
+ /// months whatever its class (A-03), and a run coarser than a month only pushes the chart to years when years
+ /// can place it (A-41).
///
- private ResolutionClass? CoarsestNeeded()
- {
- ResolutionClass? coarsest = null;
- foreach (var id in _dataLeaves)
- {
- foreach (var run in _leaves[id].CappedRuns(Now))
- {
- if (run.IsGap || run.From >= _period.To || run.To <= _period.From)
- {
- continue;
- }
-
- var resolution = run.DividedAtMonths && run.Resolution > ResolutionClass.Month ? ResolutionClass.Month : run.Resolution;
- coarsest = coarsest is { } c && c >= resolution ? c : resolution;
- }
- }
-
- return coarsest;
- }
+ private ResolutionClass? CoarsestNeeded() =>
+ ResolutionClassifier.PlanningResolution(
+ _dataLeaves
+ .SelectMany(id => _leaves[id].CappedRuns(Now))
+ .Where(run => !run.IsGap && run.From < _period.To && run.To > _period.From),
+ _zone);
// ---------------------------------------------------------------- comparison and matched coverage
diff --git a/src/Infrastructure/Dashboard/MeterDetailModels.cs b/src/Infrastructure/Dashboard/MeterDetailModels.cs
index c7747a9..63270f1 100644
--- a/src/Infrastructure/Dashboard/MeterDetailModels.cs
+++ b/src/Infrastructure/Dashboard/MeterDetailModels.cs
@@ -91,7 +91,7 @@ public sealed record RecordPage(IReadOnlyList Rows, RecordCursor? Next, in
///
/// Events in the range, newest first, at most the requested number.
/// True when the range holds more events than listed.
-/// Applicable tariffs whose validity starts inside the range, oldest first.
+/// Applicable tariffs whose validity starts inside the range, newest first (A-42).
public sealed record MeterMarkers(IReadOnlyList Events, bool MoreEvents, IReadOnlyList TariffChanges)
{
public static MeterMarkers None { get; } = new([], false, []);
diff --git a/src/Infrastructure/Dashboard/MeterDetailService.cs b/src/Infrastructure/Dashboard/MeterDetailService.cs
index 1b69d7d..7ccc623 100644
--- a/src/Infrastructure/Dashboard/MeterDetailService.cs
+++ b/src/Infrastructure/Dashboard/MeterDetailService.cs
@@ -200,7 +200,8 @@ public sealed class MeterDetailService
///
/// The tariffs that can price the meter — its own, its energy type's and the global ones — by component, then
- /// start date. All of them, not only those of a period: a price history is short.
+ /// 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)
{
@@ -213,14 +214,14 @@ public sealed class MeterDetailService
}
return await TariffsOf(db, meterId, typeId)
- .OrderBy(t => t.Component).ThenBy(t => t.ScopeType).ThenBy(t => t.ValidFrom)
+ .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.
+ /// events. Both lists are newest first (A-42), so the cap keeps the recent ones.
///
/// The meter.
/// The analysis range.
@@ -248,7 +249,7 @@ public sealed class MeterDetailService
.ToListAsync(cancellationToken).ConfigureAwait(false);
var tariffs = await TariffsOf(db, meterId, typeId)
.Where(t => t.ValidFrom >= firstDay && t.ValidFrom <= lastDay)
- .OrderBy(t => t.ValidFrom).ThenBy(t => t.Component)
+ .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);
diff --git a/src/Infrastructure/Dashboard/OverviewModels.cs b/src/Infrastructure/Dashboard/OverviewModels.cs
index ffc808d..6322c6d 100644
--- a/src/Infrastructure/Dashboard/OverviewModels.cs
+++ b/src/Infrastructure/Dashboard/OverviewModels.cs
@@ -220,8 +220,13 @@ public sealed record OverviewTypeFigures(
CostChange CostChange,
Freshness Freshness)
{
- /// True when no measure has a value for the period.
- public bool HasNoValues => Measures.All(m => m.Total.Value is null) && Cost?.Total.Cost is null;
+ ///
+ /// True when the type has nothing at all for the period: every measure is missing and nothing is charged. A
+ /// measure without a value that has a reason of its own — only coarser data, being prepared — is not "no data"
+ /// (A-41), and its own row states that reason instead.
+ ///
+ public bool HasNoValues =>
+ Measures.All(m => m.Total.Status == BucketStatus.Missing) && Cost?.Total.Cost is null;
}
/// What a row of the Overview's change table is.
diff --git a/tests/Core.Tests/Analysis/BucketPlannerTests.cs b/tests/Core.Tests/Analysis/BucketPlannerTests.cs
index c9c3e51..c1251e9 100644
--- a/tests/Core.Tests/Analysis/BucketPlannerTests.cs
+++ b/tests/Core.Tests/Analysis/BucketPlannerTests.cs
@@ -336,6 +336,7 @@ public sealed class BucketPlannerTests
// A monthly import in a daily chart would be nothing but unresolved buckets.
var monthly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Month);
var weekly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Week);
+
var coarse = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Coarse);
var month = Assert.Single(monthly.Buckets);
diff --git a/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs b/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs
index 88e4a0c..973b89e 100644
--- a/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs
+++ b/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs
@@ -335,6 +335,93 @@ public sealed class CoverageEvaluatorTests
Assert.Equal(0d, coverage.CoveredFraction);
}
+ // ---- Waiting for a measurement coarser than the bucket (A-41) ------------------------------------
+
+ [Fact]
+ public void Buckets_after_a_dipstick_wait_for_the_next_one_instead_of_reading_as_no_data()
+ {
+ // A heating-oil tank dipped on 5 October 2024 and 20 September 2025: one interval, coarser than a month.
+ // "Last 12 months" opens eleven days after the last dipstick, so nothing covers it — but the tank is
+ // neither silent nor broken, it is read twice a year. Every bucket says "only coarser data", with that
+ // resolution, and none of them claims a number.
+ var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
+
+ var october = Evaluate(Month(2025, 10), dipsticks);
+ var august = Evaluate(Month(2026, 8), dipsticks);
+
+ Assert.Equal(BucketStatus.Unresolved, october.Status);
+ Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
+ Assert.Equal(ResolutionClass.Coarse, october.Resolution);
+ Assert.Equal(TimeSpan.Zero, october.Covered);
+ Assert.Null(october.ToValue(0, Provenance.None).Value);
+ Assert.Equal(BucketStatus.Unresolved, august.Status);
+ }
+
+ [Fact]
+ public void A_bucket_before_a_meters_first_reading_is_missing_not_waiting()
+ {
+ // Nothing precedes it: the tank had not been dipped once, so there is no measurement on its way.
+ var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
+
+ Assert.Equal(BucketStatus.Missing, Evaluate(Month(2024, 3), dipsticks).Status);
+ }
+
+ [Fact]
+ public void A_meter_that_books_its_own_buckets_still_reads_as_no_data_where_it_has_none()
+ {
+ // The rule is only for data too coarse for the bucket: an hourly source that went silent in March, and a
+ // monthly sheet that ends in March, both book their buckets as they go, so a bucket they do not cover
+ // really has no data.
+ var hourly = Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Hour);
+ var sheet = MonthLabels(2026, 1, 2026, 4);
+
+ Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 4), hourly).Status);
+ Assert.Equal(BucketStatus.Missing, Evaluate(Day(2026, 4, 2), hourly).Status);
+ Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 5), sheet).Status);
+ Assert.Equal(BucketStatus.Missing, Evaluate(Year(2027), sheet).Status);
+ }
+
+ [Fact]
+ public void A_monthly_sheet_waits_a_month_for_its_next_row_and_then_has_no_data()
+ {
+ // Monthly rows cannot fill a day bucket at all, so the days right after the sheet ends are unresolved —
+ // the April row will book them. Two years on, the sheet is not late but over (A-04, LimitOf).
+ var sheet = MonthLabels(2026, 1, 2026, 4);
+
+ Assert.Equal(BucketStatus.Unresolved, Evaluate(Day(2026, 4, 2), sheet).Status);
+ Assert.Equal(ResolutionClass.Month, Evaluate(Day(2026, 4, 2), sheet).Resolution);
+ Assert.Equal(BucketStatus.Missing, Evaluate(Day(2028, 4, 2), sheet).Status);
+ }
+
+ [Fact]
+ public void A_known_hole_keeps_its_own_reason_rather_than_waiting()
+ {
+ // A gap run overlapping the bucket is a hole someone has to explain, not a measurement on its way.
+ CoverageRun[] runs =
+ [
+ Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse),
+ Gap(At(2025, 9, 20, 10), At(2026, 2, 1), CoverageGapReason.UnexplainedDecrease),
+ ];
+
+ var coverage = Evaluate(Month(2025, 11), runs);
+
+ Assert.Equal(BucketStatus.Missing, coverage.Status);
+ Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
+ }
+
+ [Fact]
+ public void A_series_reads_the_waiting_buckets_exactly_as_one_by_one()
+ {
+ var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
+ List months = [.. Enumerable.Range(0, 12).Select(i => Month(2025 + ((9 + i) / 12), (((9 + i) % 12) + 1)))];
+
+ var series = CoverageEvaluator.EvaluateSeries(months, [dipsticks], Berlin, null);
+
+ Assert.Equal(months.Select(m => Evaluate(m, dipsticks).Status), series.Select(c => c.Status));
+ Assert.All(series, c => Assert.Equal(BucketStatus.Unresolved, c.Status));
+ Assert.All(series, c => Assert.Equal(ResolutionClass.Coarse, c.Resolution));
+ }
+
[Fact]
public void Runs_that_only_touch_the_bucket_edges_do_not_cover_it()
{
diff --git a/tests/Core.Tests/Analysis/ResolutionClassifierTests.cs b/tests/Core.Tests/Analysis/ResolutionClassifierTests.cs
index 1ce2966..a152edf 100644
--- a/tests/Core.Tests/Analysis/ResolutionClassifierTests.cs
+++ b/tests/Core.Tests/Analysis/ResolutionClassifierTests.cs
@@ -92,6 +92,70 @@ public sealed class ResolutionClassifierTests
Assert.Throws(() => ResolutionClassifier.CoarsestResolving(BucketSize.Auto));
}
+ // ---- What a plotted run asks the bucket planner for (A-41) ---------------------------------------
+
+ private static CoverageRun Interval(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false) =>
+ new(from, to, resolution, divided, CoverageGapReason.None, from);
+
+ [Fact]
+ public void Quarterly_intervals_inside_one_year_ask_for_years()
+ {
+ // Read on the quarter: every interval lies inside 2025, so year buckets hold each one whole.
+ CoverageRun[] quarters =
+ [
+ Interval(InBerlin(2025, 1, 1), InBerlin(2025, 4, 1), ResolutionClass.Coarse),
+ Interval(InBerlin(2025, 4, 1), InBerlin(2025, 7, 1), ResolutionClass.Coarse),
+ Interval(InBerlin(2025, 10, 1), InBerlin(2026, 1, 1), ResolutionClass.Coarse),
+ ];
+
+ Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.PlanningResolution(quarters, Berlin));
+ Assert.Equal(BucketSize.Year, BucketPlanner.MinimumSizeFor(ResolutionClassifier.PlanningResolution(quarters, Berlin)!.Value));
+ }
+
+ [Fact]
+ public void One_interval_across_a_new_year_keeps_the_whole_chart_at_months()
+ {
+ // A dipstick read every autumn straddles the New Year as surely as every month start: year buckets leave it
+ // exactly as unresolved as month buckets do, so it must not coarsen the chart to years (A-41). A chart has
+ // one bucket size, so one such interval settles it for all of them.
+ var dipsticks = Interval(InBerlin(2025, 9, 20, 10), InBerlin(2026, 8, 1, 10), ResolutionClass.Coarse);
+ var quarter = Interval(InBerlin(2026, 1, 5), InBerlin(2026, 5, 1), ResolutionClass.Coarse);
+
+ Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([dipsticks], Berlin));
+ Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.PlanningResolution([quarter], Berlin));
+ Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([quarter, dipsticks], Berlin));
+ }
+
+ [Fact]
+ public void A_run_divided_at_the_month_edges_asks_for_months_whatever_its_length()
+ {
+ // A-03: a 46-day interval divided at 1 September is booked inside each month it touches.
+ var divided = Interval(InBerlin(2026, 8, 20, 9), InBerlin(2026, 10, 5, 9), ResolutionClass.Coarse, divided: true);
+
+ Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([divided], Berlin));
+ }
+
+ [Fact]
+ public void Gap_runs_and_an_empty_range_ask_for_nothing()
+ {
+ var hole = new CoverageRun(InBerlin(2025, 1, 1), InBerlin(2026, 6, 1), ResolutionClass.Hour, false, CoverageGapReason.SampleGap);
+
+ Assert.Null(ResolutionClassifier.PlanningResolution([], Berlin));
+ Assert.Null(ResolutionClassifier.PlanningResolution([hole], Berlin));
+ }
+
+ [Theory]
+ [InlineData(ResolutionClass.Hour)]
+ [InlineData(ResolutionClass.Day)]
+ [InlineData(ResolutionClass.Week)]
+ [InlineData(ResolutionClass.Month)]
+ public void A_run_a_bucket_size_can_place_asks_for_its_own_class(ResolutionClass resolution)
+ {
+ var run = Interval(InBerlin(2025, 12, 20), InBerlin(2026, 1, 3), resolution);
+
+ Assert.Equal(resolution, ResolutionClassifier.PlanningResolution([run], Berlin));
+ }
+
[Fact]
public void The_coarsest_of_two_classes_is_the_resolution_of_a_combined_value()
{
diff --git a/tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs b/tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs
index 1d1c135..9a23173 100644
--- a/tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs
+++ b/tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs
@@ -117,6 +117,12 @@ public sealed class AnalysisComponentRenderTests
Assert.Contains("
| Feb | ", html, StringComparison.Ordinal);
Assert.Contains(">0 kWh", html, StringComparison.Ordinal);
+
+ // A-42: the total above the rows, then March, February, January.
+ Assert.Equal(
+ ["Total", "Mar", "Feb", "Jan"],
+ System.Text.RegularExpressions.Regex.Matches(html, "([^<]+) | ")
+ .Select(m => m.Groups[1].Value));
Assert.Contains("mv-unknown\">—", html, StringComparison.Ordinal);
Assert.Contains("No data covers this period", html, StringComparison.Ordinal);
Assert.Contains("href=\"/drill/2\"", html, StringComparison.Ordinal);
@@ -286,6 +292,55 @@ public sealed class AnalysisComponentRenderTests
Assert.Contains("* Partial, estimated or not fully priced", some, StringComparison.Ordinal);
}
+ [Fact]
+ public async Task The_comparison_line_names_the_figure_it_is_about_when_a_page_shows_several()
+ {
+ // A-41: on one energy type page the tank's use shares no covered day with the year before while the burner
+ // beside it compares month by month. Unqualified, the line contradicts the card and the table under it.
+ var period = Range(D(2025, 10, 1), D(2026, 9, 30));
+ var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear), Now);
+
+ var alone = await RenderAsync("en", new()
+ {
+ [nameof(ComparisonSummary.Period)] = period,
+ [nameof(ComparisonSummary.Resolution)] = resolution,
+ [nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
+ });
+ Assert.Contains("Not comparable: the two periods share no covered days", alone, StringComparison.Ordinal);
+
+ var named = await RenderAsync("en", new()
+ {
+ [nameof(ComparisonSummary.Period)] = period,
+ [nameof(ComparisonSummary.Resolution)] = resolution,
+ [nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
+ [nameof(ComparisonSummary.Subject)] = "Total use",
+ });
+ Assert.Contains("Total use: not comparable — the two periods share no covered days", named, StringComparison.Ordinal);
+ Assert.DoesNotContain("Not comparable: the two", named, StringComparison.Ordinal);
+
+ var matched = await RenderAsync("de", new()
+ {
+ [nameof(ComparisonSummary.Period)] = period,
+ [nameof(ComparisonSummary.Resolution)] = resolution,
+ [nameof(ComparisonSummary.Matched)] = Matched(D(2025, 10, 1), D(2026, 5, 1)),
+ [nameof(ComparisonSummary.Subject)] = "Laufzeit",
+ });
+ Assert.Contains("Laufzeit: Veränderung über den gemeinsam abgedeckten Zeitraum – ", matched, StringComparison.Ordinal);
+ }
+
+ /// One matched stretch of the current period and the same stretch a year earlier.
+ private static MatchedCoverageResult Matched(DateOnly first, DateOnly last)
+ {
+ MatchedRange RangeOf(int years) => new(
+ new DateTimeOffset(first.AddYears(years).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
+ new DateTimeOffset(last.AddYears(years).AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
+ first.AddYears(years),
+ last.AddYears(years));
+
+ var piece = new MatchedPiece(RangeOf(0), RangeOf(-1));
+ return new MatchedCoverageResult(piece.Current, piece.Comparison, [piece]);
+ }
+
/// Renders a component and returns its HTML with entities decoded (the renderer encodes every non-ASCII letter).
private static async Task RenderAsync(string culture, Dictionary parameters)
where TComponent : IComponent => WebUtility.HtmlDecode(await RenderRawAsync(culture, parameters));
diff --git a/tests/Integration.Tests/Analysis/AnalysisTableModelTests.cs b/tests/Integration.Tests/Analysis/AnalysisTableModelTests.cs
index b2909ae..45ac781 100644
--- a/tests/Integration.Tests/Analysis/AnalysisTableModelTests.cs
+++ b/tests/Integration.Tests/Analysis/AnalysisTableModelTests.cs
@@ -23,19 +23,46 @@ public sealed class AnalysisTableModelTests
Assert.Equal([AnalysisTableColumnKind.Value, AnalysisTableColumnKind.Status], table.Columns.Select(c => c.Kind));
Assert.Equal("Haus", table.Columns[0].Header);
- Assert.Equal(["Jan", "Feb", "Mar", "Total"], table.Rows.Select(r => r.Label));
- Assert.Equal(buckets[1], table.Rows[1].Bucket);
- Assert.Null(table.Rows[3].Bucket);
- Assert.True(table.Rows[3].IsTotal);
+ // A-42: the total first, then the buckets newest first.
+ Assert.Equal(["Total", "Mar", "Feb", "Jan"], table.Rows.Select(r => r.Label));
+ Assert.Equal(buckets[1], table.Rows[2].Bucket);
+ Assert.Null(table.Rows[0].Bucket);
+ Assert.True(table.Rows[0].IsTotal);
- Assert.Equal(["120 kWh", "—", "0 kWh", "120 kWh"], table.Rows.Select(r => r.Cells[0].Text));
- Assert.True(table.Rows[1].Cells[0].IsUnknown);
- Assert.False(table.Rows[2].Cells[0].IsUnknown);
- Assert.Equal("Complete · Measured", table.Rows[0].Cells[1].Text);
- Assert.Equal("No data", table.Rows[1].Cells[1].Text);
- Assert.Equal("No data covers this period", table.Rows[1].Cells[1].Secondary);
- Assert.Equal("Partial · Measured", table.Rows[3].Cells[1].Text);
- Assert.Equal([false, true, false, true], table.Rows.Select(r => r.IsQualified));
+ Assert.Equal(["120 kWh", "0 kWh", "—", "120 kWh"], table.Rows.Select(r => r.Cells[0].Text));
+ Assert.True(table.Rows[2].Cells[0].IsUnknown);
+ Assert.False(table.Rows[1].Cells[0].IsUnknown);
+ Assert.Equal("Complete · Measured", table.Rows[3].Cells[1].Text);
+ Assert.Equal("No data", table.Rows[2].Cells[1].Text);
+ Assert.Equal("No data covers this period", table.Rows[2].Cells[1].Secondary);
+ Assert.Equal("Partial · Measured", table.Rows[0].Cells[1].Text);
+ Assert.Equal([true, false, true, false], table.Rows.Select(r => r.IsQualified));
+ });
+
+ [Fact]
+ public void Every_dated_table_reads_newest_first_with_the_total_above_the_rows() => In("en", () =>
+ {
+ // A-42: a table is read from the top, so the latest period is the first row; the plan itself stays
+ // chronological, and each row keeps its own bucket for the drill-down.
+ var buckets = Buckets(D(2025, 11, 1), D(2026, 2, 28));
+ var series = AnalysisTableSeries.ForSeries(
+ Series(1, "Öltank", [Available(10), Available(20), Available(30), Available(40)], total: Available(100)));
+
+ var table = AnalysisTableModel.Build(buckets, [series]);
+
+ Assert.Equal(["Total", "Feb 2026", "Jan 2026", "Dec 2025", "Nov 2025"], table.Rows.Select(r => r.Label));
+ Assert.True(table.Rows[0].IsTotal);
+ Assert.All(table.Rows.Skip(1), r => Assert.False(r.IsTotal));
+ Assert.Equal(["100 kWh", "40 kWh", "30 kWh", "20 kWh", "10 kWh"], table.Rows.Select(r => r.Cells[0].Text));
+
+ // The drill-down of a row is its own bucket, not the one at that index in the plan.
+ Assert.Equal([buckets[3], buckets[2], buckets[1], buckets[0]], table.Rows.Skip(1).Select(r => r.Bucket));
+ Assert.Equal(D(2025, 11, 1), buckets[0].FirstDay);
+
+ // Without a total row the newest bucket is simply the first row.
+ var plain = AnalysisTableModel.Build(buckets, [series], includeTotal: false);
+ Assert.Equal(["Feb 2026", "Jan 2026", "Dec 2025", "Nov 2025"], plain.Rows.Select(r => r.Label));
+ Assert.Empty(AnalysisTableModel.Build([], [series]).Rows);
});
[Fact]
@@ -58,18 +85,19 @@ public sealed class AnalysisTableModelTests
var comparison = table.Rows.Select(r => r.Cells[2]).ToList();
var change = table.Rows.Select(r => r.Cells[3]).ToList();
- Assert.Equal(["100 kWh", "90 kWh", "—", "190 kWh"], comparison.Select(c => c.Text));
- Assert.Equal(["Jan 2025", "Feb 2025", "Mar 2025", null], comparison.Select(c => c.Secondary));
+ // Newest first (A-42), so the paired bucket of each row still names the month it is compared with.
+ Assert.Equal(["190 kWh", "—", "90 kWh", "100 kWh"], comparison.Select(c => c.Text));
+ Assert.Equal([null, "Mar 2025", "Feb 2025", "Jan 2025"], comparison.Select(c => c.Secondary));
// January: both complete. February: the current month is partial. March: nothing to compare with.
- Assert.Equal("+20 kWh (+20.0 %)", change[0].Text);
- Assert.Equal("mv-change-bad", change[0].CssClass);
- Assert.Equal("—", change[1].Text);
- Assert.True(change[1].IsUnknown);
+ Assert.Equal("+20 kWh (+20.0 %)", change[3].Text);
+ Assert.Equal("mv-change-bad", change[3].CssClass);
Assert.Equal("—", change[2].Text);
+ Assert.True(change[2].IsUnknown);
+ Assert.Equal("—", change[1].Text);
// The total row states the reader's change over the matched coverage, not a sum of the rows.
- Assert.Equal("+10 kWh (+5.3 %)", change[3].Text);
+ Assert.Equal("+10 kWh (+5.3 %)", change[0].Text);
});
[Fact]
@@ -82,8 +110,9 @@ public sealed class AnalysisTableModelTests
var net = Series(2, "Bilanz", [Available(150)], kind: QuantityKind.Net, comparison: cmp);
var table = AnalysisTableModel.Build(buckets, [AnalysisTableSeries.ForSeries(generation), AnalysisTableSeries.ForSeries(net)]);
- Assert.Equal("mv-change-good", table.Rows[0].Cells[3].CssClass);
- Assert.Equal("mv-change-neutral", table.Rows[0].Cells[7].CssClass);
+ Assert.True(table.Rows[0].IsTotal);
+ Assert.Equal("mv-change-good", table.Rows[1].Cells[3].CssClass);
+ Assert.Equal("mv-change-neutral", table.Rows[1].Cells[7].CssClass);
// With two series, every column but the value names its series.
Assert.Equal("Solar", table.Columns[1].SubHeader);
@@ -101,10 +130,10 @@ public sealed class AnalysisTableModelTests
var table = AnalysisTableModel.Build(buckets, [series]);
Assert.Equal(AnalysisTableColumnKind.Cost, table.Columns[2].Kind);
- Assert.Equal(["—", "25.00 €", "25.00 €", "50.00 €"], table.Rows.Select(r => r.Cells[2].Text));
- Assert.Equal("Unavailable (tariff gap)", table.Rows[0].Cells[3].Text);
- Assert.Equal("Priced", table.Rows[1].Cells[3].Text);
- Assert.Equal("Partly priced", table.Rows[3].Cells[3].Text);
+ Assert.Equal(["50.00 €", "25.00 €", "25.00 €", "—"], table.Rows.Select(r => r.Cells[2].Text));
+ Assert.Equal("Unavailable (tariff gap)", table.Rows[3].Cells[3].Text);
+ Assert.Equal("Priced", table.Rows[2].Cells[3].Text);
+ Assert.Equal("Partly priced", table.Rows[0].Cells[3].Text);
});
[Fact]
@@ -118,10 +147,10 @@ public sealed class AnalysisTableModelTests
buckets,
[AnalysisTableSeries.ForCosts("cost", "Strom", "EUR", current.Buckets, current.Total).WithComparisonCosts(previous.Buckets, previous.Total, "EUR")]);
- Assert.Equal(["25.00 €", "25.00 €", "50.00 €"], table.Rows.Select(r => r.Cells[0].Text));
- Assert.Equal("+5.00 € (+25.0 %)", table.Rows[0].Cells[3].Text);
- Assert.Equal("mv-change-bad", table.Rows[0].Cells[3].CssClass);
- Assert.Equal("+10.00 € (+25.0 %)", table.Rows[2].Cells[3].Text);
+ Assert.Equal(["50.00 €", "25.00 €", "25.00 €"], table.Rows.Select(r => r.Cells[0].Text));
+ Assert.Equal("+5.00 € (+25.0 %)", table.Rows[1].Cells[3].Text);
+ Assert.Equal("mv-change-bad", table.Rows[1].Cells[3].CssClass);
+ Assert.Equal("+10.00 € (+25.0 %)", table.Rows[0].Cells[3].Text);
});
[Theory]
diff --git a/tests/Integration.Tests/Analysis/AnalysisUiTestData.cs b/tests/Integration.Tests/Analysis/AnalysisUiTestData.cs
index a616489..db985b4 100644
--- a/tests/Integration.Tests/Analysis/AnalysisUiTestData.cs
+++ b/tests/Integration.Tests/Analysis/AnalysisUiTestData.cs
@@ -38,15 +38,16 @@ internal static class AnalysisUiTestData
public static BucketValue Missing() => BucketValue.Missing();
- /// A physical meter's series in kWh with its total and, optionally, a comparison.
+ /// A physical meter's series in kWh (or ) with its total and, optionally, a comparison.
public static AnalysisSeries Series(
int meterId,
string name,
IReadOnlyList values,
BucketValue? total = null,
QuantityKind kind = QuantityKind.Consumption,
- SeriesComparison? comparison = null) =>
- new(SeriesKey.ForMeter(meterId, 1, "kWh"), name, SeriesBasis.Physical, kind, "kWh", values, total ?? Available(values.Sum(v => v.Value ?? 0)), IsAdditive: true)
+ SeriesComparison? comparison = null,
+ string unit = "kWh") =>
+ new(SeriesKey.ForMeter(meterId, 1, unit), name, SeriesBasis.Physical, kind, unit, values, total ?? Available(values.Sum(v => v.Value ?? 0)), IsAdditive: true)
{
Comparison = comparison,
};
diff --git a/tests/Integration.Tests/Analysis/CoarseMeterAnalysisTests.cs b/tests/Integration.Tests/Analysis/CoarseMeterAnalysisTests.cs
new file mode 100644
index 0000000..2bfd594
--- /dev/null
+++ b/tests/Integration.Tests/Analysis/CoarseMeterAnalysisTests.cs
@@ -0,0 +1,240 @@
+using MeterVault.App.Analysis;
+using MeterVault.App.Energy;
+using MeterVault.Core.Analysis;
+using MeterVault.Core.Analysis.Coverage;
+using MeterVault.Core.Analysis.Totals;
+using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Analysis;
+using MeterVault.Infrastructure.Costing;
+using MeterVault.Infrastructure.Dashboard;
+using MeterVault.Infrastructure.Options;
+using MeterVault.Integration.Tests.Costing;
+using MeterVault.App.Localization;
+using MeterVault.App.Theme;
+using static MeterVault.Integration.Tests.Costing.CostSandbox;
+
+namespace MeterVault.Integration.Tests.Analysis;
+
+///
+/// A household whose heating oil is read a few times a year — a tank dipped with a stick and a burner whose hours are
+/// noted now and then — read exactly as the energy type page reads it (A-41, reported from a real instance).
+///
+///
+///
+/// Such a meter measures more coarsely than any bucket a chart can draw, and its last reading is often months old.
+/// Three things went wrong before this fixture existed:
+///
+///
+/// -
+/// A period that opened after the last dipstick had no coverage at all, so the type's use read "No data" beside a
+/// coverage panel listing years of it, while the burner next to it read "Only coarser data" (D-14).
+///
+/// -
+/// Automatic buckets answered "last 12 months" with one bar per year, because data coarser than a month dragged the
+/// whole chart to years — which left it exactly as unresolved (D-05, A-06).
+///
+/// -
+/// The type's comparison line spoke of the use measure alone, so it claimed the periods shared no covered days while
+/// the table below compared the burner over the days both cover (D-07).
+///
+///
+/// Everything runs on the sandbox's frozen clock of 19 September 2026, 14:37 Berlin.
+///
+[Collection("Timescale")]
+public sealed class CoarseMeterAnalysisTests(TimescaleFixture fx)
+{
+ /// The tank's dipsticks: once a year, in litres, with a delivery between two of them.
+ private static readonly (DateOnly Day, double Litres)[] Dipsticks =
+ [
+ (D(2022, 10, 14), 5200),
+ (D(2023, 9, 2), 2100),
+ (D(2024, 10, 5), 2600),
+ (D(2025, 9, 20), 3100),
+ ];
+
+ ///
+ /// The burner's hour counter, read every few months — around the same days each year, so its intervals do share
+ /// covered days with the year before (which the tank's, dipped once every autumn, never do).
+ ///
+ private static readonly (DateOnly Day, double Hours)[] BurnerHours =
+ [
+ (D(2022, 10, 14), 1000),
+ (D(2023, 9, 2), 2100),
+ (D(2024, 1, 5), 2500),
+ (D(2024, 5, 1), 2900),
+ (D(2024, 8, 1), 2980),
+ (D(2024, 10, 5), 3300),
+ (D(2025, 1, 5), 3860),
+ (D(2025, 5, 1), 4286),
+ (D(2025, 8, 1), 4370),
+ (D(2025, 10, 5), 4700),
+ (D(2026, 1, 5), 5240),
+ (D(2026, 5, 1), 5629),
+ (D(2026, 8, 1), 5700),
+ ];
+
+ [Fact]
+ public async Task A_tank_dipped_once_a_year_says_its_data_is_coarser_instead_of_claiming_there_is_none()
+ {
+ await using var box = new CostSandbox(fx);
+ var (type, tank, burner) = await HeatingOilAsync(box);
+
+ var page = await LoadAsync(box, type, "/energy/1?period=12m");
+ var quantities = page.Quantities!;
+ var use = Assert.Single(quantities.Measures, m => m.Key.Measure == TotalsMeasure.Use);
+ var runtime = Assert.Single(quantities.Measures, m => m.Key.Measure == TotalsMeasure.Runtime);
+
+ // The tank was last dipped eleven days before the period opened, so nothing covers it — but it is read once a
+ // year, not silent. It says so, in the same words as the burner beside it, and with the same resolution.
+ Assert.Equal([tank], use.MemberIds);
+ Assert.Equal(BucketStatus.Unresolved, use.Total.Status);
+ Assert.Equal(ValueIssue.CoarseResolution, use.Total.Issue);
+ Assert.Equal(ResolutionClass.Coarse, use.Resolution);
+ Assert.Null(use.Total.Value);
+ Assert.Equal(BucketStatus.Unresolved, runtime.Total.Status);
+ Assert.Equal(ResolutionClass.Coarse, runtime.Resolution);
+ Assert.All(use.Values, v => Assert.Equal(BucketStatus.Unresolved, v.Status));
+
+ // The card, the coverage panel and the empty chart say one thing, and it is not "no data".
+ AnalysisUiTestData.In("en", () =>
+ {
+ var status = FigureText.Of(use.Total);
+ Assert.Equal("Only coarser data", status.Status);
+ Assert.False(status.IsKnown);
+ Assert.Equal(ChartEmptyReason.Unresolved, ChartPlanOf(quantities, use).EmptyReason);
+ Assert.Equal("Coarser than monthly", use.Resolution!.Value.Display());
+ });
+
+ // Its own coverage still reaches back years, which is what made "no data" read as a contradiction.
+ Assert.Equal((D(2022, 10, 14), D(2025, 9, 19)), (use.Availability!.FirstDay, use.Availability.LastDay));
+ Assert.Equal(D(2026, 7, 31), quantities.Availability.Quantity!.LastDay);
+ Assert.DoesNotContain(quantities.Measures, m => m.Total.Status == BucketStatus.Missing);
+ Assert.Equal([tank, burner], quantities.Series.Select(s => s.MeterId!.Value).Order());
+ }
+
+ [Fact]
+ public async Task Automatic_buckets_answer_twelve_months_with_twelve_months()
+ {
+ await using var box = new CostSandbox(fx);
+ var (type, _, _) = await HeatingOilAsync(box);
+
+ var page = await LoadAsync(box, type, "/energy/1?period=12m");
+
+ // A-41: data coarser than a month must not coarsen the chart to a bar per year; a year holds a dipstick's
+ // interval no better than a month does, and one bar is not a trend.
+ Assert.Equal(BucketSize.Auto, page.Quantities!.Plan.Requested);
+ Assert.Equal(BucketSize.Month, page.Quantities.Plan.Size);
+ Assert.Equal(12, page.Quantities.Plan.Buckets.Count);
+ Assert.Equal(D(2025, 10, 1), page.Quantities.Plan.Buckets[0].FirstDay);
+ Assert.Equal(page.Quantities.Plan.Buckets, page.Cost!.Plan.Buckets);
+ }
+
+ [Fact]
+ public async Task A_meter_read_on_the_same_days_every_year_compares_and_the_type_line_says_which_figure_it_means()
+ {
+ await using var box = new CostSandbox(fx);
+ var (type, tank, burner) = await HeatingOilAsync(box);
+
+ var page = await LoadAsync(box, type, "/energy/1?period=12m");
+ var use = Assert.Single(page.Quantities!.Measures, m => m.Key.Measure == TotalsMeasure.Use);
+ var burnerSeries = Assert.Single(page.Quantities.Series, s => s.MeterId == burner);
+
+ // The burner shares covered days with the year before; the tank, dipped every autumn, shares none. One page,
+ // two true statements — so the comparison line names the figure it is about, and the largest-changes table
+ // below states its own basis (D-07).
+ Assert.True(page.Quantities.Comparison!.Resolution.IsApplicable);
+ Assert.True(burnerSeries.Comparison!.Matched.IsComparable);
+ Assert.False(use.Comparison!.Matched.IsComparable);
+ Assert.Equal([burner], MeterChanges.Largest(page.Quantities.Series, 6).Select(c => c.Series.MeterId!.Value));
+ Assert.DoesNotContain(MeterChanges.Largest(page.Quantities.Series, 6), c => c.Series.MeterId == tank);
+ }
+
+ [Fact]
+ public async Task The_overview_the_analysis_page_and_the_tank_view_read_the_same_coarse_data()
+ {
+ await using var box = new CostSandbox(fx);
+ var (type, tank, _) = await HeatingOilAsync(box);
+ await using (var db = fx.CreateContext())
+ {
+ db.Tanks.Add(new Tank { MeterId = tank, Capacity = 7000, Unit = "L" });
+ await db.SaveChangesAsync();
+ }
+
+ var period = Preset(PeriodPreset.Last12Months);
+ var reader = Reader();
+ var clock = new FixedTimeProvider(CostSandbox.Now);
+
+ // The Analysis page's own scope (/trends?scope=meter&id=…) reads the tank exactly as the type does.
+ var trends = await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(tank), period) { Bucket = BucketSize.Auto });
+ var series = trends.SeriesFor(tank)!;
+ Assert.Equal(BucketSize.Month, trends.Plan.Size);
+ Assert.Equal(BucketStatus.Unresolved, series.Total.Status);
+ Assert.Equal(ResolutionClass.Coarse, series.Resolution);
+
+ // Tanks & consumables: usage is "only coarser data", never an empty state saying there is none.
+ var consumables = await new ConsumableService(fx, reader, new CostReader(fx, reader, OptionsOf()))
+ .GetAsync(new ConsumableRequest(period) { Bucket = BucketSize.Month });
+ var view = Assert.Single(consumables.Tanks, t => t.MeterId == tank);
+ Assert.Equal(BucketStatus.Unresolved, view.Usage!.Total.Status);
+
+ // The Overview's energy-type card reads the same measure.
+ var overview = await new DashboardService(fx, new CostService(fx, OptionsOf(), clock), clock)
+ .GetOverviewAsync(period, BucketSize.Month, new ComparisonRequest(ComparisonKind.PreviousYear));
+ var card = Assert.Single(overview.Quantities.Measures, m => m.Key.EnergyTypeId == type && m.Key.Measure == TotalsMeasure.Use);
+ Assert.Equal(BucketStatus.Unresolved, card.Total.Status);
+ Assert.False(overview.HasNoData);
+ }
+
+ // ------------------------------------------------------------------------------------------------ fixture
+
+ /// The reported instance: a dipstick tank and a burner hour counter in one energy type.
+ private async Task<(short Type, int Tank, int Burner)> HeatingOilAsync(CostSandbox box)
+ {
+ var type = await box.TypeAsync("L");
+ var tank = await box.MeterAsync(type, MeterMode.ConsumableBalance, "L", D(2022, 10, 14), name: "Öltank");
+ await using (var db = fx.CreateContext())
+ {
+ foreach (var (day, litres) in Dipsticks)
+ {
+ db.MeterEvents.Add(Event(tank, MeterEventType.TankLevel, day, litres));
+ }
+
+ db.MeterEvents.Add(Event(tank, MeterEventType.Delivery, D(2023, 9, 10), 4000));
+ await db.SaveChangesAsync();
+ }
+
+ await box.RecomputeAsync(tank);
+
+ var burner = await box.MeterAsync(type, MeterMode.RuntimeCounter, "h", D(2022, 10, 14), name: "Brenner");
+ await box.ReadingsAsync(burner, [.. BurnerHours.Select(r => (Midnight(r.Day.Year, r.Day.Month, r.Day.Day), r.Hours))]);
+ return (type, tank, burner);
+ }
+
+ private static MeterEvent Event(int meterId, MeterEventType kind, DateOnly day, double amount) =>
+ new()
+ {
+ MeterId = meterId,
+ EventType = kind,
+ Time = Midnight(day.Year, day.Month, day.Day).ToUniversalTime(),
+ Amount = amount,
+ Unit = "L",
+ };
+
+ private async Task LoadAsync(CostSandbox box, short type, string uri)
+ {
+ var reader = Reader();
+ var costs = new CostReader(fx, reader, OptionsOf());
+ var loader = new EnergyAnalysisLoader(fx, new AnalysisPeriods(reader, costs), reader, costs, new FlowService(fx, OptionsOf()));
+ var query = AnalysisQuery.Parse(uri, AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(type)));
+ return await loader.LoadAsync(type, query, CostSandbox.Now);
+ }
+
+ private AnalysisReader Reader() => new(fx, OptionsOf());
+
+ private static Microsoft.Extensions.Options.IOptions OptionsOf() =>
+ Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
+
+ /// The chart the page draws for a measure, to read why it is empty (A-28).
+ private static AnalysisChartPlan ChartPlanOf(AnalysisResult result, AnalysisSeries measure) =>
+ AnalysisChartPlan.Build(result.Plan.Buckets, [AnalysisChartSeries.ForSeries(measure, "Total use")], ChartPalette.For(isDark: true));
+}
diff --git a/tests/Integration.Tests/Analysis/EnergyPageTests.cs b/tests/Integration.Tests/Analysis/EnergyPageTests.cs
index 2aa21db..b38600b 100644
--- a/tests/Integration.Tests/Analysis/EnergyPageTests.cs
+++ b/tests/Integration.Tests/Analysis/EnergyPageTests.cs
@@ -171,25 +171,28 @@ public sealed class EnergyPageTests
// ------------------------------------------------------------------------------------------------ changes
+ /// The coverage of a whole January that both periods share.
+ private static readonly MatchedCoverageResult Matched = new(
+ new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)),
+ new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)),
+ [new MatchedPiece(new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)), new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)))]);
+
+ /// A meter that changed from to over that coverage.
+ private static AnalysisSeries Changed(int id, double current, double previous) =>
+ Series(id, $"M{id}", [Available(current)]) with
+ {
+ Comparison = new SeriesComparison([Available(previous)], Available(previous), Matched, current, previous, Change.Between(current, previous)),
+ };
+
[Fact]
public void Largest_changes_use_matched_values_and_skip_what_is_not_comparable()
{
- var matched = new MatchedCoverageResult(
- new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)),
- new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)),
- [new MatchedPiece(new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)), new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)))]);
- AnalysisSeries With(int id, double current, double previous) =>
- Series(id, $"M{id}", [Available(current)]) with
- {
- Comparison = new SeriesComparison([Available(previous)], Available(previous), matched, current, previous, Change.Between(current, previous)),
- };
-
var notComparable = Series(9, "Not comparable", [Available(5)]) with
{
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
};
- var changes = MeterChanges.Largest([With(1, 100, 90), With(2, 50, 150), With(3, 10, 10), notComparable], 2);
+ var changes = MeterChanges.Largest([Changed(1, 100, 90), Changed(2, 50, 150), Changed(3, 10, 10), notComparable], 2);
Assert.Equal([2, 1], changes.Select(c => c.Series.MeterId!.Value));
Assert.Equal(-100, changes[0].Change.Absolute!.Value, 6);
@@ -197,6 +200,94 @@ public sealed class EnergyPageTests
Assert.Equal(150, changes[0].Previous, 6);
}
+ [Fact]
+ public void A_meter_with_data_but_no_comparable_change_is_listed_with_its_value_and_its_reason() => In("en", () =>
+ {
+ // The reported case (A-43): a Heizöl type whose burner has hours in both periods and whose tank was read by
+ // dipstick only in this one. The burner ranks; the tank must still be on the page with its litres and the
+ // status that says why there is no change — not silently dropped.
+ var burner = Changed(8, 929, 986) with { Name = "Brenner" };
+ var tank = Series(7, "Öltank", [Available(2100)], total: Available(2100), unit: "L") with
+ {
+ Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
+ };
+
+ var list = MeterChanges.Of([burner, tank], 6);
+
+ Assert.Equal([8], list.Ranked.Select(c => c.Series.MeterId!.Value));
+ var row = Assert.Single(list.Rest);
+ Assert.Equal(7, row.Series.MeterId);
+ Assert.Equal(2100, row.Current.Value);
+ Assert.Equal(BucketStatus.Missing, row.Comparison.Status);
+ Assert.False(row.Change.IsAvailable);
+
+ // The reason is the reader's own wording, not new prose: the comparison side simply has no data.
+ Assert.Equal("No data", FigureText.Of(row.Comparison).Status);
+ Assert.False(row.SharesNoDays);
+ Assert.Equal(2, list.Shown);
+ Assert.Equal(0, list.Hidden);
+ Assert.False(list.IsEmpty);
+ });
+
+ [Fact]
+ public void A_meter_without_data_in_either_period_is_not_listed_at_all() => In("en", () =>
+ {
+ var silent = Series(4, "Never read", [Missing()], total: Missing()) with
+ {
+ Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
+ };
+ var onlyLastYear = Series(5, "Retired", [Missing()], total: Missing()) with
+ {
+ Comparison = new SeriesComparison([Available(40)], Available(40), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
+ };
+ var coarser = Series(6, "Monthly only", [Missing()], total: new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution));
+
+ var list = MeterChanges.Of([silent, onlyLastYear, coarser], 6);
+
+ Assert.Empty(list.Ranked);
+ Assert.Equal([6, 5], list.Rest.Select(r => r.Series.MeterId!.Value)); // by name: "Monthly only", "Retired"
+ Assert.Equal("Only coarser data", FigureText.Of(list.Rest[0].Current).Status);
+ Assert.Equal(40, list.Rest[1].Comparison.Value);
+ Assert.DoesNotContain(list.Rest, r => r.Series.MeterId == 4);
+
+ // Nothing at all to say: the panel is empty, and the page says so in one line instead of an empty table.
+ Assert.True(MeterChanges.Of([silent], 6).IsEmpty);
+ });
+
+ [Fact]
+ public void Two_known_values_over_no_shared_days_are_listed_as_not_comparable()
+ {
+ var series = Series(7, "Öltank", [Available(2100)], total: Available(2100), unit: "L") with
+ {
+ Comparison = new SeriesComparison(
+ [Available(1800)], Available(1800), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
+ };
+
+ var row = Assert.Single(MeterChanges.Of([series], 6).Rest);
+
+ Assert.True(row.SharesNoDays);
+ Assert.Equal((2100d, 1800d), (row.Current.Value, row.Comparison.Value));
+ Assert.False(row.Change.IsAvailable);
+ }
+
+ [Fact]
+ public void Neither_list_crowds_the_other_out_and_what_is_left_over_is_counted()
+ {
+ AnalysisSeries Quiet(int id) => Series(id, $"Q{id}", [Available(id)], total: Available(id)) with
+ {
+ Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
+ };
+
+ var list = MeterChanges.Of([.. Enumerable.Range(1, 5).Select(i => Changed(i, i * 10, i)), .. Enumerable.Range(10, 5).Select(Quiet)], 2);
+
+ // Two ranked and two unranked rows: a long ranking never takes the room of the meters that have no change,
+ // and the meters it pushed past its own cap queue behind them.
+ Assert.Equal([5, 4], list.Ranked.Select(c => c.Series.MeterId!.Value));
+ Assert.Equal([10, 11], list.Rest.Select(r => r.Series.MeterId!.Value));
+ Assert.Equal(6, list.Hidden);
+ Assert.Equal(4, list.Shown);
+ }
+
// ------------------------------------------------------------------------------------------------ flow words
[Fact]
diff --git a/tests/Integration.Tests/Editor/AdminPagesRenderTests.cs b/tests/Integration.Tests/Editor/AdminPagesRenderTests.cs
index 3cd30e0..9d19608 100644
--- a/tests/Integration.Tests/Editor/AdminPagesRenderTests.cs
+++ b/tests/Integration.Tests/Editor/AdminPagesRenderTests.cs
@@ -37,6 +37,7 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
Tariff[] tariffs =
[
Tariff(TariffScope.Meter, _meter, TariffComponent.UnitPrice, 1.2345, "EUR/m3"),
+ Tariff(TariffScope.Meter, _meter, TariffComponent.UnitPrice, 1.9999, "EUR/m3", new DateOnly(2023, 1, 1)),
Tariff(TariffScope.EnergyType, _type, TariffComponent.Bonus, 2.3456, "EUR"),
Tariff(TariffScope.EnergyType, _otherType, TariffComponent.UnitPrice, 9.8765, "EUR/kWh"),
];
@@ -67,9 +68,15 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
Assert.DoesNotContain("9.8765", scoped, StringComparison.Ordinal); // another type's price is not listed
Assert.Contains("Bonus, discount and tax tariffs are stored but not applied to costs yet.", scoped, StringComparison.Ordinal);
+ // A-42: inside a component the newest validity is listed first, so the price in force is the top row.
+ Assert.True(
+ scoped.IndexOf("1.9999", StringComparison.Ordinal) < scoped.IndexOf("1.2345", StringComparison.Ordinal),
+ "The 2023 price must be listed above the 2020 one.");
+
var all = WebUtility.HtmlDecode(await client.GetStringAsync(new Uri(TariffLinks.Path, UriKind.Relative)));
Assert.Contains("9.8765", all, StringComparison.Ordinal);
Assert.DoesNotContain("Tariffs that can price", all, StringComparison.Ordinal);
+ Assert.True(all.IndexOf("1.9999", StringComparison.Ordinal) < all.IndexOf("1.2345", StringComparison.Ordinal));
// The deep link of a missing price renders; its dialog opens only once the page is interactive.
var link = TariffLinks.New(TariffScope.Meter, _meter, TariffComponent.UnitPrice, new DateOnly(2027, 1, 1));
@@ -99,6 +106,6 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
Assert.Contains("Auswertungsdaten", deutsch, StringComparison.Ordinal);
}
- private static Tariff Tariff(TariffScope scope, int id, TariffComponent component, double value, string unit) =>
- new() { ScopeType = scope, ScopeId = id, Component = component, Value = value, Unit = unit, ValidFrom = new DateOnly(2020, 1, 1) };
+ private static Tariff Tariff(TariffScope scope, int id, TariffComponent component, double value, string unit, DateOnly? from = null) =>
+ new() { ScopeType = scope, ScopeId = id, Component = component, Value = value, Unit = unit, ValidFrom = from ?? new DateOnly(2020, 1, 1) };
}
diff --git a/tests/Integration.Tests/MeterPage/MeterDetailServiceTests.cs b/tests/Integration.Tests/MeterPage/MeterDetailServiceTests.cs
index 348b9b4..d06256b 100644
--- a/tests/Integration.Tests/MeterPage/MeterDetailServiceTests.cs
+++ b/tests/Integration.Tests/MeterPage/MeterDetailServiceTests.cs
@@ -202,13 +202,15 @@ public sealed class MeterDetailServiceTests(TimescaleFixture fx)
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
var other = await box.TypeAsync();
await box.TypePriceAsync(type, 0.30, D(2025, 1, 1));
+ await box.TypePriceAsync(type, 0.31, D(2025, 5, 1));
await box.TypePriceAsync(type, 0.32, D(2025, 7, 1));
await box.MeterPriceAsync(meter, 0.25, D(2025, 3, 1));
await box.TypePriceAsync(other, 0.99, D(2025, 3, 1));
await using (var db = fx.CreateContext())
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 2, 10), EventType = MeterEventType.Note, Notes = "before" });
- db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 5, 10), EventType = MeterEventType.Note, Notes = "inside" });
+ db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 4, 10), EventType = MeterEventType.Note, Notes = "inside" });
+ db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 5, 10), EventType = MeterEventType.Note, Notes = "later" });
await db.SaveChangesAsync();
}
@@ -216,16 +218,21 @@ public sealed class MeterDetailServiceTests(TimescaleFixture fx)
var markers = await service.GetMarkersAsync(
meter, new RecordRange(Midnight(2025, 3, 1), Midnight(2025, 7, 1)), D(2025, 3, 1), D(2025, 6, 30));
- Assert.Equal("inside", Assert.Single(markers.Events).Notes);
+ // Both lists newest first (A-42): the page draws them as one dated list.
+ Assert.Equal(["later", "inside"], markers.Events.Select(e => e.Notes));
Assert.False(markers.MoreEvents);
- var change = Assert.Single(markers.TariffChanges);
- Assert.Equal((TariffScope.Meter, 0.25), (change.Scope, change.Value));
+ Assert.Equal([D(2025, 5, 1), D(2025, 3, 1)], markers.TariffChanges.Select(t => t.ValidFrom));
+ Assert.Equal((TariffScope.Meter, 0.25), (markers.TariffChanges[1].Scope, markers.TariffChanges[1].Value));
- // The tariff list: the meter's own and its type's (and any global), never another type's.
+ // The tariff list: the meter's own and its type's (and any global), never another type's — newest first
+ // inside each scope, so the price that applies now is at the top (A-42).
var tariffs = await service.GetTariffsAsync(meter);
Assert.Contains(tariffs, t => t.Scope == TariffScope.Meter && t.ScopeId == meter);
- Assert.Equal(2, tariffs.Count(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type));
+ Assert.Equal(3, tariffs.Count(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type));
Assert.DoesNotContain(tariffs, t => t.Scope == TariffScope.EnergyType && t.ScopeId == other);
+ Assert.Equal(
+ [D(2025, 7, 1), D(2025, 5, 1), D(2025, 1, 1)],
+ tariffs.Where(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type).Select(t => t.ValidFrom));
}
[Fact]
diff --git a/tests/Integration.Tests/MeterPage/MeterPageLogicTests.cs b/tests/Integration.Tests/MeterPage/MeterPageLogicTests.cs
index 166469a..ca03b04 100644
--- a/tests/Integration.Tests/MeterPage/MeterPageLogicTests.cs
+++ b/tests/Integration.Tests/MeterPage/MeterPageLogicTests.cs
@@ -278,4 +278,35 @@ public sealed class MeterPageLogicTests
Assert.NotNull(MeterProjection.For(MonthSeries(1800, ResolutionClass.Month, start, Now.AddDays(-10)), ytd));
Assert.Null(MeterProjection.For(MonthSeries(1800, ResolutionClass.Month, start, Now.AddDays(-100)), ytd));
}
+
+ // -------------------------------------------------------------------------------------------------- markers
+
+ [Fact]
+ public void The_period_markers_are_one_dated_list_newest_first() => In("en", () =>
+ {
+ // A-42: events and price changes are drawn as one list, so they interleave by date instead of running down
+ // twice. On one day the event comes first, then the price that starts with it.
+ DateTimeOffset At(int month, int day) =>
+ new DateTimeOffset(2026, month, day, 12, 0, 0, TimeSpan.Zero).ToUniversalTime();
+
+ var markers = new MeterMarkers(
+ [
+ new EventRow(3, At(5, 20), MeterEventType.Delivery, 2000, null, null, "L", null, null),
+ new EventRow(2, At(3, 4), MeterEventType.Note, null, null, null, null, "new tenant", null),
+ ],
+ MoreEvents: false,
+ [
+ new TariffRow(1, TariffScope.EnergyType, 3, TariffComponent.UnitPrice, 0.9, "EUR/L", D(2026, 3, 4), null),
+ new TariffRow(2, TariffScope.Meter, 7, TariffComponent.UnitPrice, 1.1, "EUR/L", D(2026, 1, 1), null),
+ ]);
+
+ var list = MeterMarkerList.Of(markers, Berlin, "Heizöl");
+
+ Assert.Equal([D(2026, 5, 20), D(2026, 3, 4), D(2026, 3, 4), D(2026, 1, 1)], list.Select(m => m.Day));
+ Assert.Equal("Delivery: 2,000.0 L", list[0].Text);
+ Assert.Equal("Note: new tenant", list[1].Text); // the event of 4 March …
+ Assert.Contains("Heizöl", list[2].Text, StringComparison.Ordinal); // … then the type price starting that day
+ Assert.Contains("This meter", list[3].Text, StringComparison.Ordinal);
+ Assert.Empty(MeterMarkerList.Of(MeterMarkers.None, Berlin, "Heizöl"));
+ });
}