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:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// 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).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// 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).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// 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).
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// <para>Everything runs on the sandbox's frozen clock of 19 September 2026, 14:37 Berlin.</para>
|
||||
/// </remarks>
|
||||
[Collection("Timescale")]
|
||||
public sealed class CoarseMeterAnalysisTests(TimescaleFixture fx)
|
||||
{
|
||||
/// <summary>The tank's dipsticks: once a year, in litres, with a delivery between two of them.</summary>
|
||||
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),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>The reported instance: a dipstick tank and a burner hour counter in one energy type.</summary>
|
||||
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<EnergyAnalysis> 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<MeterVaultOptions> OptionsOf() =>
|
||||
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
|
||||
|
||||
/// <summary>The chart the page draws for a measure, to read why it is empty (A-28).</summary>
|
||||
private static AnalysisChartPlan ChartPlanOf(AnalysisResult result, AnalysisSeries measure) =>
|
||||
AnalysisChartPlan.Build(result.Plan.Buckets, [AnalysisChartSeries.ForSeries(measure, "Total use")], ChartPalette.For(isDark: true));
|
||||
}
|
||||
Reference in New Issue
Block a user