Analysis: read a rarely-read meter as coarse, not absent; newest rows first
ci / build-test (push) Successful in 2m41s
ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped a few times a year and its burner read every few months, which is exactly the shape the coverage rules had not been walked through. "No data" for data that exists. A tank books nothing until the next dipstick closes the interval, so the stretch after the last dipstick is covered by no run at all, and a bucket no run covers was reported missing. The burner, whose run reaches into the window, said "only coarser data" -- the honest answer -- so one card claimed there was nothing while the coverage panel beside it listed years of data. A bucket that no run covers, no gap overlaps and no opening balance explains now reports the meter's resolution when its preceding coverage is within one interval of its own class: it is not silent, it is read rarely. A meter that does book its own buckets and stops -- a dead hourly source, a sheet asked about a later month -- still reads missing. Auto answering twelve months with one bar. Coarse only means "longer than a month", so a dipstick taken each autumn straddles a New Year as surely as a month start: coarsening the chart to years bought nothing and cost every point. The planning resolution now caps coarse at month when a run crosses a local year edge, and a series that cannot resolve the natural size no longer coarsens the whole chart -- it is drawn at that size with its buckets marked, which the chart and table already explain. A page contradicting itself. The comparison line above the ranking was fed the leading measure's matched coverage but worded as if it spoke for the page, directly above a burner row that did compare. It now names the figure it is about. Alongside: the "largest changes" ranking no longer drops a meter whose change is not comparable. It ranks what can be ranked, then lists the rest with their values and the reason -- the tank had been vanishing from its own energy type. And every dated table now reads newest first, as lists are read; charts stay chronological left to right, and the CSV export stays ascending for spreadsheets. A-41 to A-43 in the note record the three rules.
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user