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
@@ -171,25 +171,28 @@ public sealed class EnergyPageTests
// ------------------------------------------------------------------------------------------------ changes
/// <summary>The coverage of a whole January that both periods share.</summary>
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)))]);
/// <summary>A meter that changed from <paramref name="previous"/> to <paramref name="current"/> over that coverage.</summary>
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]