Files
MeterVault/tests/Core.Tests/Analysis/ResolutionClassifierTests.cs
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
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.
2026-09-20 12:44:32 +02:00

166 lines
8.0 KiB
C#

using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The resolution classes of D-13, at their exact limits: an hour and a minute, 25 hours, seven days and
/// an hour, 31 days and two hours. The slack is what keeps real intervals — a late poll, a DST day, a
/// month with a DST hour — in the class they belong to. There is one classifier (A-09); the bucket side of
/// the same scale — which class a bucket size needs — is pinned here too.
/// </summary>
public sealed class ResolutionClassifierTests
{
public static TheoryData<TimeSpan, ResolutionClass> Limits => new()
{
{ TimeSpan.Zero, ResolutionClass.Hour },
{ TimeSpan.FromMinutes(5), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(60), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(61), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(61) + TimeSpan.FromTicks(1), ResolutionClass.Day },
{ TimeSpan.FromHours(23), ResolutionClass.Day },
{ TimeSpan.FromHours(25), ResolutionClass.Day },
{ TimeSpan.FromHours(25) + TimeSpan.FromSeconds(1), ResolutionClass.Week },
{ TimeSpan.FromDays(7) + TimeSpan.FromHours(1), ResolutionClass.Week },
{ TimeSpan.FromDays(7) + TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(1), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(2), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(2) + TimeSpan.FromSeconds(1), ResolutionClass.Coarse },
{ TimeSpan.FromDays(365 * 12), ResolutionClass.Coarse },
};
[Theory]
[MemberData(nameof(Limits))]
public void An_interval_is_classified_by_its_length_up_to_each_limit_inclusive(TimeSpan length, ResolutionClass expected)
{
Assert.Equal(expected, ResolutionClassifier.Classify(length));
}
[Fact]
public void A_negative_length_claims_no_time_and_is_the_finest_class()
{
Assert.Equal(ResolutionClass.Hour, ResolutionClassifier.Classify(TimeSpan.FromHours(-3)));
}
[Fact]
public void The_long_autumn_day_is_still_a_day_and_october_with_its_extra_hour_still_a_month()
{
Assert.Equal(TimeSpan.FromHours(25), InBerlin(2026, 10, 26) - InBerlin(2026, 10, 25));
Assert.Equal(ResolutionClass.Day, ResolutionClassifier.Classify(InBerlin(2026, 10, 25), InBerlin(2026, 10, 26)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 10, 1), InBerlin(2026, 11, 1)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2027, 2, 1), InBerlin(2027, 3, 1)));
}
[Fact]
public void A_week_read_an_hour_late_is_still_a_week_but_two_weeks_are_not()
{
Assert.Equal(ResolutionClass.Week, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 11)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 21, 10)));
}
[Theory]
[InlineData(ResolutionClass.Hour, 61)]
[InlineData(ResolutionClass.Day, 25 * 60)]
[InlineData(ResolutionClass.Week, (7 * 24 * 60) + 60)]
[InlineData(ResolutionClass.Month, (31 * 24 * 60) + 120)]
public void Each_class_admits_intervals_up_to_its_own_limit(ResolutionClass resolution, int minutes)
{
Assert.Equal(TimeSpan.FromMinutes(minutes), ResolutionClassifier.LimitOf(resolution));
Assert.Equal(resolution, ResolutionClassifier.Classify(ResolutionClassifier.LimitOf(resolution)));
}
[Fact]
public void Data_coarser_than_a_month_has_no_limit()
{
Assert.Equal(TimeSpan.MaxValue, ResolutionClassifier.LimitOf(ResolutionClass.Coarse));
}
[Theory]
[InlineData(BucketSize.Day, ResolutionClass.Day)]
[InlineData(BucketSize.Week, ResolutionClass.Week)]
[InlineData(BucketSize.Month, ResolutionClass.Month)]
[InlineData(BucketSize.Year, ResolutionClass.Month)]
public void A_bucket_size_names_the_coarsest_class_that_resolves_it_outright(BucketSize size, ResolutionClass expected)
{
Assert.Equal(expected, ResolutionClassifier.CoarsestResolving(size));
}
[Fact]
public void Auto_has_no_class_because_it_is_resolved_before_any_bucket_exists()
{
Assert.Throws<ArgumentOutOfRangeException>(() => 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()
{
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Coarsest(ResolutionClass.Hour, ResolutionClass.Month));
Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.Coarsest(ResolutionClass.Coarse, ResolutionClass.Day));
}
}