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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user