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
+2 -1
View File
@@ -196,6 +196,7 @@ sources (Tasmota/HA/MQTT/manual/CSV)
- `Invalid`: a calculation failed.
- `Pending`: the meter is being rebuilt.
- It is derived from coverage runs and their resolution class (≤ 1 h, ≤ 1 day, ≤ 7 days, ≤ 1 month, coarser; A-02, A-03), never from the amount.
- A bucket no run covers is `Unresolved`, not `Missing`, when the meter measures more coarsely than that bucket and is simply not read yet — a tank dipped once a year books nothing until the next dipstick (`CoverageEvaluator.AwaitsMeasurement`, A-41). Such data also stops `auto` at month buckets (`ResolutionClassifier.PlanningResolution`), and a comparison line on a page with several figures names the one it is about (`ComparisonSummary.Subject`).
- Separate dimensions: `Provenance` flags (measured, manual, imported, estimated, derived, opening balance), `ValueIssue` (why a value is not plain) and freshness (stale live source vs historical import, D-18).
- Outside `[InstalledAt, RetiredAt]` a meter is a known zero (D-24).
- A first reading with unknown start is an opening balance: partial, excluded from comparisons, with "Set install date" offered (A-01).
@@ -251,7 +252,7 @@ sources (Tasmota/HA/MQTT/manual/CSV)
- The initial load happens in `OnParametersSet`, because render tests read prerendered HTML. Subscribe to `Nav.LocationChanged` for query-only changes, and dispose.
- **Shared components** (`Components/Shared/Analysis`): `PageHeader`, `AnalysisBreadcrumbs` (Overview → type → meter, carrying the period), `PeriodToolbar` (presets, custom dates with one Apply, bucket with the refused-size hint, comparison incl. calendar years, metric, Reset, "Export CSV"), `AnalysisChart`, `AnalysisTable`, `MetricCard`, `ChangeChip`, `ValueStatus`, `EmptyPeriodState` (available dates + "Go to latest data"), `PendingState`, `PanelError`, `LoadPanel`, `RefreshIndicator`, `ProjectionNote`, `ComparisonSummary`, `AttentionList`, `SeriesContributions`.
- `AnalysisChart` (ApexCharts) has one axis per unit, nullable points, no smoothing or joining across gaps, and a real zero line for signed data. It follows the theme.
- `AnalysisTable` is the accessible equivalent of every chart and scrolls in its own region.
- `AnalysisTable` is the accessible equivalent of every chart and scrolls in its own region. Its rows read **newest first** with the total above them, as every dated list here does (A-42); the chart beside it and the CSV export stay chronological. "Largest changes by meter" also lists the meters it cannot rank, with their values and their status (`MeterChanges.Of`, A-43).
- Their rules are pure classes in `App/Analysis`: `FigureText` (the status words beside every figure, culprit meters by name), `ChangeDisplay` (metric polarity: more generation is good), `AnalysisChartModel`/`AnalysisChartOptions`, `AnalysisTableModel`, `AttentionItems` (D-53: one targeted action each, e.g. `TariffLinks.For(MissingPrice)``/admin/tariffs?scope=&id=&component=&from=&action=new`), `FormulaText`.
- **Missing ≠ zero ≠ not priced** on every page (brief §4.3, A-28):
- A true zero is a number and an outlined bar on the baseline. An unknown bucket is a gap marked "" in the chart and "—" plus its reason in the table. Qualified values (partial, estimated) are marked "*".
+80
View File
@@ -9,6 +9,7 @@ The note was kept current through Phase 5:
- §12: amendments from the acceptance review.
- §13: decisions recorded with the final documentation.
- §14: amendments from the performance measurement.
- §15: amendments from the first reports of a live instance.
- §9 and §10: extended with what the implementation measured and changed.
The outcome is in [ANALYSIS_REPORT.md](ANALYSIS_REPORT.md), the user-facing changes in
@@ -738,3 +739,82 @@ value changes; only what a request reads does.
and passes that snapshot to all three (`CostReader.ReadAsync(db, catalog, …)`), so every figure answers from the
same snapshot and the load is not repeated. The cards, the change table and the composition are derived from those
results and are never priced again. `OverviewReadBudgetTests` asserts the statement count of one load.
## 15. Amendments from the first reports of a live instance
Three findings from one real instance: imported history only, a Heizöl type whose tank is dipped with a stick
a few times a year and whose burner hours are noted every few months. Two of them are about what a figure says
when the measurement is coarser than the question, one is about the order and the completeness of the lists
beside it. No figure, golden reconciliation number or seeded bill total changes.
- **A-41 A meter read a few times a year (D-05, D-13, D-14, D-07, A-02, A-03, A-06, A-24).** An instance with imported
history only, a heating-oil tank dipped with a stick once a year and a burner whose hours are noted every few months,
showed that three separate rules break down when a meter's *measurement* is coarser than the question asked of it.
The pages it reported are the energy type page, the Overview, the Analysis page and Tanks & consumables; all four read
the two readers, so all four are fixed by the three changes below.
- **A bucket waiting for a measurement is unresolved, not empty.** A dipstick interval books nothing until the next
dipstick closes it, so "last 12 months" opening eleven days after the last one is covered by no run at all. The
coverage evaluator called that *missing* — "No data for this period" — beside a coverage panel listing four years of
it, and beside a burner that read "Only coarser data" for the same period. It is the same situation A-04 already
names for a bucket that ends at now: what has accrued since the last reading is not yet known, and that is no
shortfall. So a bucket that no run covers, that no *gap* run overlaps (a known hole keeps its own reason) and that
holds no opening balance is `Unresolved` with `CoarseResolution` when the meter's last coverage before it cannot
place a bucket of that size anyway, and lies within one interval of its own class (`LimitOf`, unbounded for data
coarser than a month). It carries that resolution, so the card, the coverage panel, the chart's "only coarser data"
and the table agree; it carries no number, because there is none. A meter that books its own buckets as it goes —
an hourly source gone silent, a monthly sheet asked about a later month — is unchanged: a bucket it does not cover
really has no data. `CoverageEvaluator.AwaitsMeasurement`, pinned by `CoverageEvaluatorTests`.
- **Auto coarsens for the data only as far as the data gains by it.** `ResolutionClass.Coarse` means no more than
"longer than a local month", and `MinimumSizeFor` maps it to years. Auto therefore answered a 12-month request with
one bar per year — which resolved exactly nothing, because a dipstick taken every autumn straddles a New Year as
surely as it straddles every month start, so the yearly buckets were unresolved too. The floor now comes from
`ResolutionClassifier.PlanningResolution(runs, zone)`: one decision for the whole chart (a chart has one bucket
size, D-05), the coarsest class among the plotted runs, with divided runs counted as monthly (A-03) and with the
class capped at `Month` as soon as any coarse run in the range crosses a local year edge. A meter read on the
quarter, whose intervals all lie inside one year, still charts in years — years do hold each of those whole
(`CostReviewFixTests`). The 400-point limit and explicit sizes are untouched, and `MinimumSizeFor` keeps its
meaning for drill-downs (D-51) and for the coarser interval the empty chart offers (A-28).
- **A comparison line names the figure it is about.** Matched coverage (D-07) belongs to one series: on one page the
tank's use shared no covered day with the year before while the burner beside it compared over October to May. The
page stated the *use* measure's verdict — "Not comparable: the periods share no covered days" — directly above a
table that showed the burner's matched comparison, and beside a runtime card showing its change. `ComparisonSummary`
takes a `Subject`, and the energy type's Overview and History, Solar and the Overview's cost line pass the figure
the line speaks of whenever they show more than one. Both statements are then true. The energy type's coverage
panel also lists a measure's own availability whenever it differs from the scope's, which is what made "no data"
read as a contradiction in the first place.
- **A-42 Every dated list reads newest first.**
- A table is read from the top, so its first row is the period the reader is in. `AnalysisTableModel.Build` turns the
plan round once, after the rows are built and paired with the comparison by index, and puts the **total row above**
them — it sums what follows it. Every page that renders `AnalysisTable` inherits this: the meter's Analysis tab, the
energy type's History tab, `/trends`, Solar, Tanks & consumables and the Overview's history table. The plan itself
is never reordered, so nothing that indexes it (drill-downs, comparison pairs, chart series) has to know.
- The same rule applies to the other dated lists that were still ascending: a virtual meter's calculation preview
(and its capped warnings), the tariffs of a meter's Tariffs tab and of `/admin/tariffs` (newest validity first
inside each component, so the price in force is at the top), and the tariff changes among the meter page's
contextual markers. The markers are now **one** list: events and price changes interleave by local day
(`MeterMarkerList`), because two lists one after the other made the dates run down, jump back up and run down again.
- Already newest first, and left alone: the record tabs (readings, normalized rows, events — D-50 keysets), the
deliveries of a tank, and the import batches.
- **The chart stays chronological** — it is read left to right — and so does the **CSV export** (D-55): a file is
sorted, charted and differenced by whatever opens it, and every one of those expects time to run forwards.
- Lists that are not dated keep their own order: meters by energy type and name, the flow table by node name, a
virtual meter's source contributions by the formula, connectors and sources by name and priority.
- **A-43 A meter with no comparable change is listed, not dropped.**
- "Largest changes by meter" ranked the meters whose change could be measured over the days both periods cover (D-07)
and showed nothing at all about the others. On the reported Heizöl page that left only the burner: the tank, read by
dipstick a few times a year, has no matched coverage against last year and so vanished although it has data.
- `MeterChanges.Of` now returns three things: the ranked changes (unchanged rule), **every other meter that has data
in either period**, and how many the caps left over. A row of the second list carries the meter's two totals as the
reader read them, so the page words each of them with the same status text every other figure gets (`FigureText`,
`DisplayNames`): "No data", "Only coarser data", "Being prepared". Only one case needs more than that — both totals
known yet no shared day — and it is said with the page's existing sentence, `Comparison_NotComparable`.
- A meter with nothing on either side stays out: repeating "no data" twice is not information.
- The two lists are capped separately, and the unranked one is ordered with the meters that have no change at all in
front, so a long ranking can never crowd out the very rows this fix is about. What neither list holds is counted and
said out loud ("Showing 4 of 10 meters"), never silently dropped.
- The caption "A change is measured over the dates both periods cover" now stands above the ranked rows only.
- **Checked and left as they are:** the Overview's "What changed" (`DashboardService.LineRows`/`CategoryRows`) already
ranks rows without a comparable change last instead of dropping them; it drops only rows with no *money* on either
side, and those are exactly the ones the composition panel lists with their price coverage and the attention list
offers a tariff for (D-42, D-53). The attention list caps with a "show all", the meter list and the flow table drop
nothing.
+11
View File
@@ -150,6 +150,15 @@ Back up the database (`pg_dump`) first.
- MudBlazor's own labels are in German too.
- Pages work at phone width.
- Amounts use the configured currency (`MeterVault__Currency`) instead of a hard-coded €.
- On a page that shows several figures, the line about the compared dates names the figure it is about, so it can no
longer say "not comparable" above a table that compares another meter month by month (A-41).
- Every dated table reads newest first, with the total above the rows it sums: the meter's Analysis tab, an energy
type's History, the Analysis page, Solar, Tanks & consumables, the tariff lists and a virtual meter's preview. The
meter page's events and price changes are one dated list instead of two. Charts and the CSV export stay
chronological, because that is how they are read (A-42).
- "Largest changes by meter" no longer leaves out a meter it cannot rank: one that has data but no comparable change —
a tank dipped a few times a year — is listed under "Other meters" with both its values and the reason there is no
change. A meter with nothing on either side stays out, and whatever the caps leave over is counted out loud (A-43).
- Deleting a meter or an energy type also deletes the tariffs scoped to it.
- JSON export/import now carries meter connections, re-links virtual formulas to the new meter ids, and no longer
restores the tariffs of deleted meters onto other meters.
@@ -170,6 +179,8 @@ Einsparung) is unchanged. The seeded yearly bill matches the sheet's `Jahreskost
| Price of a longer bucket | Month buckets used the price of the 15th, year buckets the price of 1 July | Every bucket is priced month by month at the price of the 15th, so a year equals the sum of its months and changing the bucket never changes a total (D-36) |
| Months in which the billed grid meter was not yet (or no longer) in service | — (0.3.0 summed every meter) | Unavailable rather than free while use was measured, with an attention item naming the grid meter and the months (A-17) |
| Tank, runtime, direct-delta or instant-rate readings more than a month apart (a tank dipped every few months, quarterly burner hours) | Booked whole in the month of the later reading, with zeros in between | The months in between read "only coarser data" (not zero). A year or longer bucket is priced when all its months share one price; otherwise it is unavailable with an attention item (A-16) |
| A period that opens after the last reading of a meter read more coarsely than it (a tank dipped once a year, last dipped shortly before the period began) | "No data for this period", beside a coverage panel listing years of data | "Only coarser data", naming the resolution: the next dipstick will book it. The card, the chart, the table and the coverage panel now say the same thing, and the panel adds a measure's own dates when they differ from the type's. A meter that books its own buckets — an hourly source gone silent, a monthly sheet asked about a later month — still reads "no data" (A-41) |
| Automatic interval with data coarser than a month | The whole chart went to years, which resolved nothing: "last 12 months" became one bar per year | Months, with the buckets marked "only coarser data" and the coarser interval one click away. A meter read on the quarter, whose intervals lie inside one year, still charts in years (A-41) |
| Manual costs | Counted in the Overview but not in the trend; a cost dated later this month counted at once | Counted once, on their start day, when that day has come, everywhere: Overview, Analysis, categories, export (D-41) |
| Cost categories | Sum of their member meters' costs | The priced non-overlapping cover of their members plus their manual costs. Seeded Strom = Netz × price. A category overlapping another is shown as a view, apart from the composition. A category whose members price nothing says so (D-42, A-22) |
| Virtual meters | No analysis; the flow summed incoming links, ignoring any formula | Full analysis from the stored formula; the Sankey uses the same values (D-27, D-30) |
+8
View File
@@ -42,9 +42,17 @@ public sealed record AnalysisCsvRow(
/// or a script reads the same figures the page shows, never a fabricated zero.
/// </summary>
/// <remarks>
/// <para>
/// Names and units are user data. A cell that starts with <c>=</c>, <c>+</c>, <c>-</c>, <c>@</c> or a control character
/// is prefixed with an apostrophe, so a spreadsheet shows it as text instead of running it as a formula; numbers are
/// written by this class and never need it.
/// </para>
/// <para>
/// <b>Chronological, unlike the pages (A-42).</b> The rows stay oldest first, in the plan's own order. The tables on
/// screen read newest first because a reader starts at the top; a CSV is not read, it is sorted, charted and
/// differenced, and every tool that does so — a spreadsheet's chart, a running total, a diff against last month's
/// file — expects time to run forwards. Reversing it here would only make every consumer sort it back.
/// </para>
/// </remarks>
public static class AnalysisCsvWriter
{
+17 -3
View File
@@ -201,14 +201,23 @@ public sealed record AnalysisTableRow(AnalysisBucket? Bucket, string Label, IRea
/// says in words what the chart only marks.
/// </summary>
/// <remarks>
/// <para>
/// A change is stated per bucket only where both figures are complete — a partial bucket against a whole one is not a
/// like-for-like change (D-07); the total row takes the reader's change over the matched coverage. Unknown values read
/// "—", never 0.
/// </para>
/// <para>
/// <b>Newest first (A-42).</b> The rows are the reverse of the plan: the latest bucket is the first row, and the total
/// row sits above them all. The chart stays chronological — it is read left to right — but a table is read top down,
/// and what a reader wants first is the period they are in. The plan itself is never reordered: <see cref="Build"/>
/// takes the buckets oldest first (as the reader returns them, paired with the comparison by index) and turns them
/// round once, here, so nothing that indexes the plan has to know.
/// </para>
/// </remarks>
public sealed record AnalysisTableModel(IReadOnlyList<AnalysisTableColumn> Columns, IReadOnlyList<AnalysisTableRow> Rows)
{
/// <summary>Builds the table.</summary>
/// <param name="buckets">The buckets, oldest first.</param>
/// <summary>Builds the table: the total row first, then the buckets newest first (A-42).</summary>
/// <param name="buckets">The buckets, oldest first (the plan's own order).</param>
/// <param name="series">The series.</param>
/// <param name="pairs">The comparison buckets paired with <paramref name="buckets"/>, to name each row's compared bucket.</param>
/// <param name="includeTotal">Adds the total row.</param>
@@ -258,6 +267,10 @@ public sealed record AnalysisTableModel(IReadOnlyList<AnalysisTableColumn> Colum
rows.Add(new AnalysisTableRow(buckets[i], labels[i], cells, IsTotal: false, qualified));
}
// A-42: the latest period is the first row. The pairing above is by index against the plan, so the order is
// turned round only once everything is built.
rows.Reverse();
if (includeTotal && buckets.Count > 0)
{
var cells = new List<AnalysisTableCell>(columns.Count);
@@ -269,7 +282,8 @@ public sealed record AnalysisTableModel(IReadOnlyList<AnalysisTableColumn> Colum
AddCells(cells, item, total, item.Costs is null ? null : item.CostTotal ?? Unknown, item.Comparison is null ? null : item.ComparisonTotal ?? Unknown, null, item.TotalChange);
}
rows.Add(new AnalysisTableRow(null, Strings.AnalysisTable_Total, cells, IsTotal: true, qualified));
// Above the rows, where a total belongs once they descend: it sums what follows it, not what precedes it.
rows.Insert(0, new AnalysisTableRow(null, Strings.AnalysisTable_Total, cells, IsTotal: true, qualified));
}
return new AnalysisTableModel(columns, rows);
+2 -1
View File
@@ -233,7 +233,8 @@ else
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
// By component, then the newest validity first (A-42): a price history is read from the price that applies now.
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenByDescending(t => t.ValidFrom).ToListAsync();
_effectiveEnds = TariffValidity.EffectiveEnds(
_tariffs.Select(t => new TariffSpan(t.Id, t.ScopeType, t.ScopeId, t.Component, t.ValidFrom, t.ValidTo)));
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
+1 -1
View File
@@ -120,7 +120,7 @@
@* The ranges compared; the matched stretch only when less than the whole periods is compared (D-07). *@
<ComparisonSummary Period="view.Period" Resolution="view.Data.Comparison"
Matched="@(view.Data.CostChange.IsPartial || view.Data.CostChange.Basis == CostChangeBasis.NotComparable ? view.Data.CostChange.Matched : null)"
Class="mt-3" />
Subject="@S.AnalysisTable_Cost" Class="mt-3" />
<MudGrid Spacing="3" Class="mt-1">
<MudItem xs="12">
@@ -40,7 +40,8 @@
}
else
{
<ComparisonSummary Period="Analysis.Period" Resolution="ComparisonResolution()" Matched="_view.Main?.Comparison?.Matched" Class="mb-3" />
<ComparisonSummary Period="Analysis.Period" Resolution="ComparisonResolution()" Matched="_view.Main?.Comparison?.Matched"
Subject="@MainName()" Class="mb-3" />
<AnalysisChart Buckets="_buckets" Series="_view.Chart" ComparisonPairs="_pairs" Title="@_title" OnBucketClick="_onBucketClick"
Resolution="_view.Coarsest" OnUseBucket="UseBucket" />
@@ -188,6 +189,12 @@
private MeterVault.Core.Analysis.Coverage.AvailableRange? Availability() =>
_view?.Main?.Availability ?? _quantities?.Availability.Quantity;
/// <summary>
/// The name of the series the comparison line speaks of, when the view charts several whose matched coverage
/// may differ (A-41); null when it charts one and the line cannot be misread.
/// </summary>
private string? MainName() => _view is { Table.Count: > 1 } view ? view.Table[0].Name : null;
private ComparisonResolution? ComparisonResolution() =>
_view?.Metric == AnalysisMetric.Cost ? Analysis.CostComparison?.Resolution : _quantities?.Comparison?.Resolution;
@@ -1,5 +1,6 @@
@using MeterVault.App.Energy
@using MeterVault.Core.Analysis
@using MeterVault.Core.Analysis.Coverage
@using MeterVault.Core.Analysis.Totals
@using MeterVault.Infrastructure.Analysis
@inject NavigationManager Nav
@@ -59,7 +60,8 @@ else
}
</MudGrid>
<ComparisonSummary Period="Analysis.Period" Resolution="_quantities.Comparison?.Resolution" Matched="_main?.Comparison?.Matched" Class="mt-3 mb-4" />
<ComparisonSummary Period="Analysis.Period" Resolution="_quantities.Comparison?.Resolution" Matched="_main?.Comparison?.Matched"
Subject="@_comparisonSubject" Class="mt-3 mb-4" />
<MudGrid Spacing="3">
<MudItem xs="12" md="8">
@@ -69,7 +71,7 @@ else
<MudLink Href="@HistoryHref(_main is null ? null : AnalysisMetrics.MetricOf(_main.Kind))" Typo="Typo.body2">@S.EnergyView_OpenHistory</MudLink>
</div>
<AnalysisChart Buckets="_quantities.Plan.Buckets" Series="_trend" ComparisonPairs="_quantities.Comparison?.Buckets"
Title="@_trendTitle" Height="240" />
Title="@_trendTitle" Height="240" Resolution="_main?.Resolution" />
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
@@ -102,6 +104,12 @@ else
{
<div class="mv-cell-secondary">@detail</div>
}
@* Its own dates whenever they are not the scope's: a measure counted by a tank dipped
once a year must not be read against the range of the burner beside it (A-41). *@
@if (OwnRange(series) is { } own)
{
<div class="mv-cell-secondary">@Loc.F(S.Empty_AvailableRange, Format.Date(own.FirstDay), Format.Date(own.LastDay))</div>
}
</dd>
}
</dl>
@@ -109,18 +117,23 @@ else
</MudItem>
<MudItem xs="12">
<MudPaper Outlined="true" Elevation="0" Class="pa-4">
@* A-43: the ranked changes, then every other meter that has data in either period — with its values and
the status that says why there is no change. A meter with data is never silently left out. *@
<MudText Typo="Typo.h6">@S.EnergyView_LargestChanges</MudText>
@if (_quantities.Comparison is not { IsApplicable: true })
{
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparison</MudText>
}
else if (_changes.Count == 0)
else if (_changes.IsEmpty)
{
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparableMeters</MudText>
}
else
{
@if (_changes.Ranked.Count > 0)
{
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-2">@S.EnergyView_LargestChangesNote</MudText>
}
<div class="mv-table-scroll" role="region" aria-label="@S.EnergyView_LargestChanges" tabindex="0">
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
<thead>
@@ -132,17 +145,11 @@ else
</tr>
</thead>
<tbody>
@foreach (var change in _changes)
@foreach (var change in _changes.Ranked)
{
var series = change.Series;
<tr>
<th scope="row" class="mv-row-label">
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)">@series.Name</MudLink>
@if (Analysis.TotalsOf(series.MeterId!.Value) is { IsCounted: false } entry)
{
<div class="mv-cell-secondary">@entry.Class.Display()</div>
}
</th>
<th scope="row" class="mv-row-label">@MeterCell(series)</th>
<td class="mv-num">
@Format.Quantity(change.Current, series.Unit)
<div class="mv-cell-secondary">@Format.DateRange(change.Matched.Current!.FirstDay, change.Matched.Current.LastDay)</div>
@@ -158,8 +165,46 @@ else
</tr>
}
</tbody>
@if (_changes.Rest.Count > 0)
{
<tbody>
@if (_changes.Ranked.Count > 0)
{
<tr class="mv-row-group">
<th scope="colgroup" colspan="4">
@S.EnergyView_ChangesRest
<div class="mv-cell-secondary">@S.EnergyView_ChangesRestNote</div>
</th>
</tr>
}
@foreach (var row in _changes.Rest)
{
var series = row.Series;
<tr>
<th scope="row" class="mv-row-label">@MeterCell(series)</th>
@FigureCell(row.Current, series)
@FigureCell(row.Comparison, series)
<td>
<ChangeChip Change="row.Change" Polarity="ChangePolarities.For(series.Kind)"
FormatMagnitude="@(v => Format.Quantity(v, series.Unit))" />
@if (row.SharesNoDays)
{
<div class="mv-cell-secondary">@S.Comparison_NotComparable</div>
}
</td>
</tr>
}
</tbody>
}
</MudSimpleTable>
</div>
@if (_changes.Hidden > 0)
{
<MudText Typo="Typo.body2" Class="mv-muted mt-2">
@Loc.F(S.EnergyView_MoreMeters, _changes.Shown, _changes.Shown + _changes.Hidden)
<MudLink Href="@AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabMeters, Query)" Typo="Typo.body2">@S.Nav_Meters</MudLink>
</MudText>
}
}
</MudPaper>
</MudItem>
@@ -192,9 +237,10 @@ else
private List<AnalysisSeries> _measures = [];
private AnalysisSeries? _main;
private IReadOnlyList<AnalysisChartSeries> _trend = [];
private IReadOnlyList<MeterChange> _changes = [];
private MeterChangeList _changes = MeterChangeList.None;
private string _trendTitle = string.Empty;
private string? _comparisonCaption;
private string? _comparisonSubject;
private string? _costBasis;
private List<string> _costLines = [];
private Change? _costChange;
@@ -224,6 +270,8 @@ else
_noData = _measures.Count > 0 && _measures.All(s => s.Total.Status == BucketStatus.Missing && !s.IsPending);
_comparisonCaption = _quantities.Comparison is { IsApplicable: true } ? Query.Comparison.Display() : null;
_comparisonSubject = _measures.Count > 1 && _main is { } named ? EnergyHistoryView.MeasureName(named, _measures) : null;
if (_main is { } main)
{
var name = EnergyHistoryView.MeasureName(main, _measures);
@@ -242,10 +290,16 @@ else
_trend = [];
}
_changes = MeterChanges.Largest(_quantities.Series, MaxChanges);
_changes = MeterChanges.Of(_quantities.Series, MaxChanges);
BuildCost();
}
/// <summary>A measure's own availability when it differs from the scope's (A-41); null when they are the same.</summary>
private AvailableRange? OwnRange(AnalysisSeries series) =>
series.Availability is { } own && (_quantities?.Availability.Quantity is not { } scope || own.FirstDay != scope.FirstDay || own.LastDay != scope.LastDay)
? own
: null;
private void BuildCost()
{
_costBasis = null;
@@ -292,6 +346,32 @@ else
private Change? ChangeOf(AnalysisSeries series) =>
_quantities?.Comparison is { IsApplicable: true } ? series.Comparison?.Change ?? Change.Unavailable : null;
/// <summary>The meter's name, linked to its own analysis over the same period, and — when it is not counted in the totals — why.</summary>
private RenderFragment MeterCell(AnalysisSeries series) => __builder =>
{
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)">@series.Name</MudLink>
@if (Analysis.TotalsOf(series.MeterId!.Value) is { IsCounted: false } entry)
{
<div class="mv-cell-secondary">@entry.Class.Display()</div>
}
};
/// <summary>
/// A total of a meter that is not ranked: its number, or the status in words instead of a fabricated 0 (§4.3). A
/// value that is not plain keeps its status beside it — that is the reason there is no change.
/// </summary>
private RenderFragment FigureCell(BucketValue value, AnalysisSeries series) => __builder =>
{
var status = FigureText.Of(value, Analysis.Names.MeterOrNull);
<td class="@(status.IsKnown ? "mv-num" : "mv-num mv-unknown")">
@(status.IsKnown ? Format.Quantity(value.Value, series.Unit) : Format.Unknown)
@if (!status.IsComplete)
{
<div class="mv-cell-secondary">@status.Summary</div>
}
</td>
};
private string? MembersCaption(AnalysisSeries series) =>
series.MemberIds.Count == 0 ? null : Loc.F(S.EnergyView_CountedMeters, string.Join(", ", series.MemberIds.Select(Analysis.MeterName)));
@@ -102,21 +102,14 @@
@if (!View.Markers.IsEmpty)
{
<MudText Typo="Typo.subtitle1" Class="mt-4 mb-1">@S.MeterDetail_InThisPeriod</MudText>
@* One dated list, newest first (A-42): events and price changes interleave, so the dates never jump back up. *@
<ul class="mv-markers">
@foreach (var e in View.Markers.Events)
@foreach (var marker in Markers)
{
<li>
<MudIcon Icon="@MeterEventText.Icon(e.Type)" Size="Size.Small" aria-hidden="true" />
<span class="mv-markers__date">@Format.Date(PeriodResolver.LocalDate(e.Time, View.Period.Zone))</span>
<span>@e.Type.Display()@EventDetail(e)</span>
</li>
}
@foreach (var t in View.Markers.TariffChanges)
{
<li>
<MudIcon Icon="@Icons.Material.Filled.Sell" Size="Size.Small" aria-hidden="true" />
<span class="mv-markers__date">@Format.Date(t.ValidFrom)</span>
<span>@Loc.F(S.MeterDetail_TariffChange, t.Component.Display(), Format.Number(t.Value, 4), t.Unit, ScopeText(t))</span>
<MudIcon Icon="@marker.Icon" Size="Size.Small" aria-hidden="true" />
<span class="mv-markers__date">@Format.Date(marker.Day)</span>
<span>@marker.Text</span>
</li>
}
</ul>
@@ -157,6 +150,9 @@
private AnalysisSeries? Series => View.Series;
/// <summary>The events and tariff changes of the range as one dated list, newest first (A-42).</summary>
private IReadOnlyList<MeterMarker> Markers => MeterMarkerList.Of(View.Markers, View.Period.Zone, Detail.EnergyType);
/// <summary>A first reading booked against the baseline with an unknown start (D-14) sits in the range.</summary>
private bool HasOpeningBalance =>
Series is { } s && (s.Total.Provenance.HasFlag(Provenance.OpeningBalance) || s.Values.Any(v => v.Provenance.HasFlag(Provenance.OpeningBalance)));
@@ -214,19 +210,4 @@
return MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, target);
}
private static string EventDetail(EventRow e) => e.Type switch
{
MeterEventType.Delivery or MeterEventType.TankLevel when e.Amount is { } amount => $": {Format.Number(amount, 1)} {e.Unit}".TrimEnd(),
MeterEventType.MeterSwap or MeterEventType.CounterReset when e.PrevValue is not null || e.NewValue is not null =>
$": {(e.PrevValue is { } p ? Format.Number(p, 2) : Format.Unknown)} → {(e.NewValue is { } n ? Format.Number(n, 2) : Format.Unknown)}",
_ when !string.IsNullOrWhiteSpace(e.Notes) => ": " + e.Notes,
_ => string.Empty,
};
private string ScopeText(TariffRow tariff) => tariff.Scope switch
{
TariffScope.Meter => S.MeterDetail_ScopeThisMeter,
TariffScope.EnergyType => Detail.EnergyType,
_ => tariff.Scope.Display(),
};
}
@@ -43,8 +43,9 @@
private int _stale;
/// <summary>
/// The period as a whole: complete when every measure is, no data when none has any, being prepared while a rebuild
/// runs, partial otherwise. Without any meter, the bill's own availability (manual costs are always available).
/// The period as a whole: complete when every measure is, no data when none has any, only coarser data when every
/// measure has only that (A-41), being prepared while a rebuild runs, partial otherwise. Without any meter, the
/// bill's own availability (manual costs are always available).
/// </summary>
private BucketStatus PeriodStatus
{
@@ -68,6 +69,7 @@
return statuses.All(s => s == BucketStatus.Available) ? BucketStatus.Available
: statuses.All(s => s == BucketStatus.Missing) ? BucketStatus.Missing
: statuses.All(s => s == BucketStatus.Unresolved) ? BucketStatus.Unresolved
: BucketStatus.Partial;
}
}
@@ -105,7 +105,7 @@
</MudGrid>
<ComparisonSummary Period="Site.Quantities.Period" Resolution="Site.Quantities.Comparison?.Resolution"
Matched="Site.Generation?.Comparison?.Matched" Class="mb-3" />
Matched="Site.Generation?.Comparison?.Matched" Subject="@S.Solar_Generation" Class="mb-3" />
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mb-4">
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationAndSelfConsumption</MudText>
@@ -1,7 +1,8 @@
@* The accessible alternative to the chart (brief §7.2): one row per bucket with the period label, each series' value
with its unit, the status in words (availability, reason, provenance), the optional cost and price coverage, the
optional comparison (with the bucket it is compared with) and the change, a total row, and a drill-down per row.
A wide table scrolls inside its own container, never the page. *@
A wide table scrolls inside its own container, never the page.
The rows read newest first with the total above them (A-42); the chart beside it stays chronological. *@
@if (_model is not null && _model.Rows.Count > 0)
{
@@ -79,7 +80,7 @@
}
@code {
/// <summary>The buckets of the plan, oldest first.</summary>
/// <summary>The buckets of the plan, oldest first; the table turns them round itself (A-42).</summary>
[Parameter, EditorRequired]
public IReadOnlyList<AnalysisBucket> Buckets { get; set; } = [];
@@ -1,6 +1,10 @@
@* Which dates are compared (D-06, D-07, brief §4.2): the current period and the comparison as requested, and the
stretch both actually cover, over which a change is stated. When nothing matches, the comparison is "not
comparable": absolute values only, no percentage. A comparison that does not apply says why. *@
comparable": absolute values only, no percentage. A comparison that does not apply says why.
Matched coverage belongs to one figure, not to the page: a tank dipped once a year shares no day with the year
before while the burner beside it compares month by month. A page that shows several figures therefore names the
one this line is about (Subject, A-41), so the line never contradicts a card or a table below it. *@
@if (Resolution is { } resolution)
{
@@ -14,13 +18,14 @@
{
<div class="mv-comparison__warning">
<MudIcon Icon="@Icons.Material.Outlined.Info" Size="MudBlazor.Size.Small" aria-hidden="true" />
@S.Comparison_NotComparable
@(Named is { } subject ? Loc.F(S.Comparison_NotComparableFor, subject) : S.Comparison_NotComparable)
</div>
}
else if (matched.Current is { } current && matched.Comparison is { } other)
{
var (from, to) = (Format.DateRange(current.FirstDay, current.LastDay), Format.DateRange(other.FirstDay, other.LastDay));
<div class="mv-muted">
@Loc.F(S.Comparison_Matched, Format.DateRange(current.FirstDay, current.LastDay), Format.DateRange(other.FirstDay, other.LastDay))@(matched.IsContiguous ? string.Empty : " " + S.Comparison_WithGaps)
@(Named is { } subject ? Loc.F(S.Comparison_MatchedFor, subject, from, to) : Loc.F(S.Comparison_Matched, from, to))@(matched.IsContiguous ? string.Empty : " " + S.Comparison_WithGaps)
</div>
}
}
@@ -45,8 +50,17 @@
[Parameter]
public MeterVault.Core.Analysis.Coverage.MatchedCoverageResult? Matched { get; set; }
/// <summary>
/// The figure this line is about (a measure's or a meter's name, "Cost"), when the page shows several whose
/// matched coverage may differ; null when there is only one and the line cannot be misread.
/// </summary>
[Parameter]
public string? Subject { get; set; }
[Parameter]
public string? Class { get; set; }
private string CurrentRange => Format.PeriodRange(Period);
private string? Named => string.IsNullOrWhiteSpace(Subject) ? null : Subject.Trim();
}
@@ -58,19 +58,8 @@
}
</tr>
</thead>
@* Newest month first, the total above them (A-42), as every dated table here reads. *@
<tbody>
@for (var i = 0; i < preview.Buckets.Count; i++)
{
var index = i;
<tr>
<th scope="row" class="mv-row-label">@Format.BucketLabel(preview.Buckets[index], preview.SpansYears)</th>
@Cell(series.Values[index], series.Unit, strong: true)
@foreach (var source in preview.Sources)
{
@Cell(index < source.Values.Count ? source.Values[index] : null, Model.Option(source.MeterId)?.Unit ?? series.Unit, strong: false)
}
</tr>
}
<tr class="mv-total-row">
<th scope="row" class="mv-row-label">@S.MeterEditor_PreviewTotalRow</th>
@Cell(series.Total, series.Unit, strong: true)
@@ -79,6 +68,18 @@
@Cell(source.Total, Model.Option(source.MeterId)?.Unit ?? series.Unit, strong: false)
}
</tr>
@for (var i = preview.Buckets.Count - 1; i >= 0; i--)
{
var index = i;
<tr>
<th scope="row" class="mv-row-label">@Format.BucketLabel(preview.Buckets[index], preview.SpansYears)</th>
@Cell(index < series.Values.Count ? series.Values[index] : null, series.Unit, strong: true)
@foreach (var source in preview.Sources)
{
@Cell(index < source.Values.Count ? source.Values[index] : null, Model.Option(source.MeterId)?.Unit ?? series.Unit, strong: false)
}
</tr>
}
</tbody>
</MudSimpleTable>
</div>
@@ -212,8 +213,10 @@
List<SourceColumn> sources = [.. series.Contributions.Select(c => new SourceColumn(c.MeterId, c.Values, c.Total))];
List<AnalysisChartSeries> chart = [AnalysisChartSeries.ForSeries(series, series.Name)];
var spansYears = Format.SpansYears(buckets);
// Newest first (A-42): the list is capped, and the months a reader is about to look at are the recent ones.
List<string> warnings = [];
for (var i = 0; i < buckets.Count && i < series.Values.Count; i++)
for (var i = Math.Min(buckets.Count, series.Values.Count) - 1; i >= 0; i--)
{
var status = FigureText.Of(series.Values[i], model.Name);
if (!status.IsComplete)
+98 -4
View File
@@ -15,6 +15,38 @@ namespace MeterVault.App.Energy;
/// <param name="Matched">The matched ranges.</param>
public sealed record MeterChange(AnalysisSeries Series, double Current, double Previous, Change Change, MatchedCoverageResult Matched);
/// <summary>
/// A meter listed beside the ranking (A-43): it has data in one of the two periods but is not ranked — because nothing
/// can be measured over dates both periods cover, because one side has no data or only coarser data, or because it is
/// an indicator, which is never ranked against quantities. Its totals are the ones the reader read, so the page can
/// word each of them with the same status text every other figure gets (D-14).
/// </summary>
/// <param name="Series">The meter's series.</param>
/// <param name="Current">Its total over the requested period, with its status.</param>
/// <param name="Comparison">Its total over the comparison period, with its status; missing when none was read.</param>
/// <param name="Change">Its own change when it has one (an indicator, a meter past the ranking's cap); unavailable otherwise.</param>
/// <param name="SharesNoDays">Both totals are known, yet the two periods share no covered day (D-07).</param>
public sealed record MeterChangeRow(
AnalysisSeries Series, BucketValue Current, BucketValue Comparison, Change Change, bool SharesNoDays);
/// <summary>
/// The rows of "largest changes by meter" (brief §7.3, A-43): the ranked changes, the meters with data but no ranked
/// change, and how many meters the caps left out.
/// </summary>
/// <param name="Ranked">The comparable meters, largest change first.</param>
/// <param name="Rest">The remaining meters with data in either period, by name.</param>
/// <param name="Hidden">Meters with data that neither list has room for; 0 when everything is shown.</param>
public sealed record MeterChangeList(IReadOnlyList<MeterChange> Ranked, IReadOnlyList<MeterChangeRow> Rest, int Hidden)
{
public static MeterChangeList None { get; } = new([], [], 0);
/// <summary>How many meters the panel lists.</summary>
public int Shown => Ranked.Count + Rest.Count;
/// <summary>True when no meter of the scope has data in either period.</summary>
public bool IsEmpty => Shown == 0 && Hidden == 0;
}
/// <summary>The Overview tab's "largest changes by meter" (brief §7.3).</summary>
public static class MeterChanges
{
@@ -32,10 +64,7 @@ public static class MeterChanges
var changes = series
.Where(s => s.MeterId is not null && s.Kind != QuantityKind.Indicator)
.Select(s => s.Comparison is { Matched.IsComparable: true, CurrentMatched: { } current, ComparisonMatched: { } previous } comparison
&& comparison.Change is { IsAvailable: true } change
? new MeterChange(s, current, previous, change, comparison.Matched)
: null)
.Select(ChangeOf)
.OfType<MeterChange>()
.ToList();
@@ -46,4 +75,69 @@ public static class MeterChanges
return [.. ordered.ThenBy(c => c.Series.Name, StringComparer.CurrentCultureIgnoreCase).Take(max)];
}
/// <summary>
/// Every meter the panel has something to say about (A-43): the ranked changes first, then the meters that have
/// data in this period or in the comparison but no ranked change — with their totals and their status, never
/// dropped. A meter with no data on either side is not listed: there is nothing to say about it here.
/// </summary>
/// <remarks>
/// The reason a row is not ranked is never invented here: it is the status of the two totals (no data, only
/// coarser data, being prepared) plus <see cref="MeterChangeRow.SharesNoDays"/> for the one case both figures are
/// known and still nothing can be compared (D-07). Each list is capped on its own, so a long ranking can never
/// crowd out the meters that have no change — what neither holds is counted in <see cref="MeterChangeList.Hidden"/>
/// and said out loud.
/// </remarks>
/// <param name="series">The meters' series (<see cref="AnalysisResult.Series"/>) of a read with a comparison.</param>
/// <param name="max">How many rows each of the two lists holds at most.</param>
public static MeterChangeList Of(IEnumerable<AnalysisSeries> series, int max)
{
ArgumentNullException.ThrowIfNull(series);
ArgumentOutOfRangeException.ThrowIfNegative(max);
var meters = series.Where(s => s.MeterId is not null).ToList();
var ranked = Largest(meters, max);
var rankedKeys = ranked.Select(c => c.Series.Key).ToHashSet();
// Those with no change at all come first: they are the ones the ranking cannot show, and the reason this list
// exists. Meters past the ranking's cap follow — they are only ranked further down, not unexplained.
List<MeterChangeRow> rest =
[
.. meters
.Where(s => !rankedKeys.Contains(s.Key) && HasData(s))
.Select(Row)
.OrderBy(r => r.Change.IsAvailable)
.ThenBy(r => r.Series.Name, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(r => r.Series.MeterId!.Value),
];
var hidden = Math.Max(0, rest.Count - max);
return new MeterChangeList(ranked, hidden > 0 ? rest[..max] : rest, hidden);
}
/// <summary>The meter's comparable change, or null when nothing can be measured over dates both periods cover.</summary>
private static MeterChange? ChangeOf(AnalysisSeries series) =>
series.Comparison is { Matched.IsComparable: true, CurrentMatched: { } current, ComparisonMatched: { } previous } comparison
&& comparison.Change is { IsAvailable: true } change
? new MeterChange(series, current, previous, change, comparison.Matched)
: null;
/// <summary>
/// True when the reader has something other than "no data" to say about the meter on at least one side — a value,
/// a partial or coarser figure, a rebuild in progress, a failed calculation. A meter missing on both sides is left
/// out: it would only repeat "no data" twice.
/// </summary>
private static bool HasData(AnalysisSeries series) =>
series.Total.Status != BucketStatus.Missing
|| (series.Comparison is { } comparison && comparison.Total.Status != BucketStatus.Missing);
private static MeterChangeRow Row(AnalysisSeries series)
{
var comparison = series.Comparison;
var previous = comparison?.Total ?? BucketValue.Missing();
var change = comparison?.Change ?? Change.Unavailable;
var sharesNoDays = comparison is { Matched.IsComparable: false }
&& series.Total.Value is not null && previous.Value is not null;
return new MeterChangeRow(series, series.Total, previous, change, sharesNoDays);
}
}
+14 -2
View File
@@ -438,9 +438,15 @@
<data name="Comparison_Matched" xml:space="preserve">
<value>Veränderung über den gemeinsam abgedeckten Zeitraum: {0} und {1}.</value>
</data>
<data name="Comparison_MatchedFor" xml:space="preserve">
<value>{0}: Veränderung über den gemeinsam abgedeckten Zeitraum {1} und {2}.</value>
</data>
<data name="Comparison_NotComparable" xml:space="preserve">
<value>Nicht vergleichbar: Die Zeiträume haben keine gemeinsam abgedeckten Tage, daher werden nur die Werte gezeigt.</value>
</data>
<data name="Comparison_NotComparableFor" xml:space="preserve">
<value>{0}: Nicht vergleichbar die Zeiträume haben keine gemeinsam abgedeckten Tage, daher werden nur die Werte gezeigt.</value>
</data>
<data name="Comparison_Ranges" xml:space="preserve">
<value>{0} im Vergleich zu {1}</value>
</data>
@@ -897,6 +903,12 @@
<data name="EnergyView_AddConnectionAction" xml:space="preserve">
<value>Verbinden</value>
</data>
<data name="EnergyView_ChangesRest" xml:space="preserve">
<value>Weitere Zähler</value>
</data>
<data name="EnergyView_ChangesRestNote" xml:space="preserve">
<value>Zähler mit Daten, die in der Rangfolge nicht vorkommen — mit ihren Werten und ihrem Status.</value>
</data>
<data name="EnergyView_ChartTitle" xml:space="preserve">
<value>{0} — {1}</value>
</data>
@@ -1015,7 +1027,7 @@
<value>Größte Veränderungen je Zähler</value>
</data>
<data name="EnergyView_LargestChangesNote" xml:space="preserve">
<value>Jede Veränderung wird über die Tage gemessen, die beide Zeiträume abdecken.</value>
<value>Eine Veränderung wird über die Tage gemessen, die beide Zeiträume abdecken.</value>
</data>
<data name="EnergyView_Legend" xml:space="preserve">
<value>Legende</value>
@@ -1051,7 +1063,7 @@
<value>{0} von {1} Zählern angezeigt. Die übrigen vergleichen Sie unter:</value>
</data>
<data name="EnergyView_NoComparableMeters" xml:space="preserve">
<value>Kein Zähler hat in beiden Zeiträumen Daten über dieselben Tage.</value>
<value>Kein Zähler dieser Energieart hat Daten in diesem Zeitraum oder im Vergleich.</value>
</data>
<data name="EnergyView_NoComparison" xml:space="preserve">
<value>Wählen Sie einen Vergleich, um zu sehen, welche Zähler sich am stärksten verändert haben.</value>
+14 -2
View File
@@ -438,9 +438,15 @@
<data name="Comparison_Matched" xml:space="preserve">
<value>Change measured over what both cover: {0} and {1}.</value>
</data>
<data name="Comparison_MatchedFor" xml:space="preserve">
<value>{0}: change measured over what both cover — {1} and {2}.</value>
</data>
<data name="Comparison_NotComparable" xml:space="preserve">
<value>Not comparable: the two periods share no covered days, so only the values are shown.</value>
</data>
<data name="Comparison_NotComparableFor" xml:space="preserve">
<value>{0}: not comparable — the two periods share no covered days, so only the values are shown.</value>
</data>
<data name="Comparison_Ranges" xml:space="preserve">
<value>{0} compared with {1}</value>
</data>
@@ -897,6 +903,12 @@
<data name="EnergyView_AddConnectionAction" xml:space="preserve">
<value>Connect</value>
</data>
<data name="EnergyView_ChangesRest" xml:space="preserve">
<value>Other meters</value>
</data>
<data name="EnergyView_ChangesRestNote" xml:space="preserve">
<value>Meters with data that the ranking does not cover, with their values and their status.</value>
</data>
<data name="EnergyView_ChartTitle" xml:space="preserve">
<value>{0} — {1}</value>
</data>
@@ -1015,7 +1027,7 @@
<value>Largest changes by meter</value>
</data>
<data name="EnergyView_LargestChangesNote" xml:space="preserve">
<value>Each change is measured over the dates both periods cover.</value>
<value>A change is measured over the dates both periods cover.</value>
</data>
<data name="EnergyView_Legend" xml:space="preserve">
<value>Legend</value>
@@ -1051,7 +1063,7 @@
<value>Showing {0} of {1} meters. Compare the others on:</value>
</data>
<data name="EnergyView_NoComparableMeters" xml:space="preserve">
<value>No meter has data over matching dates in both periods.</value>
<value>No meter of this energy type has data in this period or in the comparison.</value>
</data>
<data name="EnergyView_NoComparison" xml:space="preserve">
<value>Choose a comparison to see which meters changed most.</value>
+66
View File
@@ -0,0 +1,66 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Dashboard;
using MudBlazor;
namespace MeterVault.App.MeterDetails;
/// <summary>One dated marker beside the meter's chart: a lifecycle event or a price change.</summary>
/// <param name="Day">The local day it falls on.</param>
/// <param name="Icon">Its icon.</param>
/// <param name="Text">What it says, in the reader's language; a note and a meter's name stay as entered.</param>
public sealed record MeterMarker(DateOnly Day, string Icon, string Text);
/// <summary>
/// The contextual markers of the meter page (brief §7.2) as one list: the events and the tariff changes of the range
/// interleaved by their local day, newest first (A-42). Two lists one after the other would make the dates run down,
/// jump back up and run down again; one list reads as the period does.
/// </summary>
public static class MeterMarkerList
{
/// <summary>
/// The markers of <paramref name="markers"/>, newest first; on one day an event comes before the price that
/// starts with it, because the event is what happened and the price is what it is billed at afterwards.
/// </summary>
/// <param name="markers">The events and tariff changes the reader read (<see cref="MeterMarkers"/>).</param>
/// <param name="zone">The instance zone an event's instant is dated in.</param>
/// <param name="energyTypeName">The meter's energy type (user data), for a tariff scoped to it.</param>
public static IReadOnlyList<MeterMarker> Of(MeterMarkers markers, TimeZoneInfo zone, string energyTypeName)
{
ArgumentNullException.ThrowIfNull(markers);
ArgumentNullException.ThrowIfNull(zone);
return
[
.. markers.Events
.Select(e => (Marker: new MeterMarker(
PeriodResolver.LocalDate(e.Time, zone), MeterEventText.Icon(e.Type), e.Type.Display() + Detail(e)), Order: 0))
.Concat(markers.TariffChanges.Select(t => (Marker: new MeterMarker(
t.ValidFrom,
Icons.Material.Filled.Sell,
Loc.F(Strings.MeterDetail_TariffChange, t.Component.Display(), Format.Number(t.Value, 4), t.Unit, ScopeText(t, energyTypeName))), Order: 1)))
.OrderByDescending(m => m.Marker.Day)
.ThenBy(m => m.Order)
.Select(m => m.Marker),
];
}
/// <summary>What an event booked: an amount, a register boundary, or its note.</summary>
private static string Detail(EventRow e) => e.Type switch
{
MeterEventType.Delivery or MeterEventType.TankLevel when e.Amount is { } amount => $": {Format.Number(amount, 1)} {e.Unit}".TrimEnd(),
MeterEventType.MeterSwap or MeterEventType.CounterReset when e.PrevValue is not null || e.NewValue is not null =>
$": {(e.PrevValue is { } p ? Format.Number(p, 2) : Format.Unknown)} → {(e.NewValue is { } n ? Format.Number(n, 2) : Format.Unknown)}",
_ when !string.IsNullOrWhiteSpace(e.Notes) => ": " + e.Notes,
_ => string.Empty,
};
/// <summary>Whose price it is: this meter's, its energy type's (named) or the global one.</summary>
private static string ScopeText(TariffRow tariff, string energyTypeName) => tariff.Scope switch
{
TariffScope.Meter => Strings.MeterDetail_ScopeThisMeter,
TariffScope.EnergyType => energyTypeName,
_ => tariff.Scope.Display(),
};
}
+2 -1
View File
@@ -99,7 +99,8 @@ h1:focus {
.mv-num { text-align: right; font-variant-numeric: tabular-nums; }
.mv-cell-secondary { font-size: 0.75rem; color: var(--mud-palette-text-secondary); }
.mv-row-label { font-weight: 400; text-align: left; }
.mv-row-total th, .mv-row-total td { font-weight: 600; border-top: 2px solid var(--mud-palette-lines-default); }
.mv-row-group th { text-align: left; font-weight: 600; padding-top: 12px; border-top: 1px solid var(--mud-palette-lines-default); white-space: normal; }
.mv-row-total th, .mv-row-total td { font-weight: 600; border-bottom: 2px solid var(--mud-palette-lines-default); }
.mv-qualified { font-style: italic; }
.mv-unknown { color: var(--mud-palette-text-secondary); }
.mv-drill { width: 1%; }
+95 -25
View File
@@ -12,8 +12,10 @@ namespace MeterVault.Core.Analysis.Coverage;
/// <item><description>
/// <b>Missing</b> when no non-gap run overlaps the bucket. The issue is the reason of a gap run overlapping
/// it (<see cref="ValueIssue.RegisterDiscontinuity"/> for an unexplained decrease or a reset without a previous
/// value, <see cref="ValueIssue.SampleGap"/>), otherwise <see cref="ValueIssue.NoCoverage"/>. Exception: a
/// bucket holding an opening balance is partial, never missing, because a value is booked in it.
/// value, <see cref="ValueIssue.SampleGap"/>), otherwise <see cref="ValueIssue.NoCoverage"/>. Two exceptions: a
/// bucket holding an opening balance is partial, never missing, because a value is booked in it; and a bucket
/// waiting for a measurement coarser than itself is unresolved, never missing (A-41, see
/// <c>AwaitsMeasurement</c>) — a dipstick read twice a year has not yet booked the months since the last one.
/// </description></item>
/// <item><description>
/// <b>Unresolved</b> (<see cref="ValueIssue.CoarseResolution"/>) when any overlapping non-gap run fails the
@@ -106,7 +108,7 @@ public static class CoverageEvaluator
var usable = Usable(runs, now, zone);
var covering = usable.Where(r => !r.IsGap && Overlaps(r, bucket)).ToList();
var gaps = usable.Where(r => r.IsGap && Overlaps(r, bucket)).ToList();
return EvaluateCore(bucket, covering, gaps, zone, checkResolution: true, openingBalanceInBucket, now);
return EvaluateCore(bucket, covering, gaps, zone, checkResolution: true, openingBalanceInBucket, now, Preceding(usable, bucket));
}
/// <summary>
@@ -138,8 +140,8 @@ public static class CoverageEvaluator
var results = new BucketCoverage[buckets.Count];
foreach (var i in order)
{
var (covering, gaps) = sweep.Overlapping(buckets[i]);
results[i] = EvaluateCore(buckets[i], covering, gaps, zone, checkResolution: true, openingBalanceInBucket?[i] ?? false, now);
var (covering, gaps, preceding) = sweep.Overlapping(buckets[i]);
results[i] = EvaluateCore(buckets[i], covering, gaps, zone, checkResolution: true, openingBalanceInBucket?[i] ?? false, now, preceding);
}
return results;
@@ -192,7 +194,8 @@ public static class CoverageEvaluator
zone,
checkResolution: true,
openingBalance: false,
now))
now,
Preceding(runs, bucket)))
.ToList();
return Combine(jointCoverage, sourceCoverage);
@@ -227,11 +230,11 @@ public static class CoverageEvaluator
foreach (var i in order)
{
var bucket = buckets[i];
var (jointCovering, jointGaps) = jointSweep.Overlapping(bucket);
var (jointCovering, jointGaps, _) = jointSweep.Overlapping(bucket);
var joint = EvaluateCore(bucket, jointCovering, jointGaps, zone, checkResolution: false, openingBalanceInBucket?[i] ?? false, now);
var sourceCoverage = sourceSweeps
.Select(sweep => sweep.Overlapping(bucket))
.Select(o => EvaluateCore(bucket, o.Covering, o.Gaps, zone, checkResolution: true, openingBalance: false, now))
.Select(o => EvaluateCore(bucket, o.Covering, o.Gaps, zone, checkResolution: true, openingBalance: false, now, o.Preceding))
.ToList();
results[i] = Combine(joint, sourceCoverage);
}
@@ -250,16 +253,10 @@ public static class CoverageEvaluator
ArgumentNullException.ThrowIfNull(zone);
// The class rule alone settles day and week buckets, and month and year buckets for day-or-finer data
// (an interval of at most 25 hours crosses a month edge by less than the tolerance). An undivided week-
// or month-class interval across a month edge is its own run (CoverageBuilder), so the edge tolerance
// below judges it exactly (D-14).
if (run.Resolution <= ResolutionClassifier.CoarsestResolving(bucket.Size)
&& (bucket.Size is BucketSize.Day or BucketSize.Week || run.Resolution <= ResolutionClass.Day))
{
return true;
}
if (run.DividedAtMonths && bucket.Size is BucketSize.Month or BucketSize.Year)
// (an interval of at most 25 hours crosses a month edge by less than the tolerance) and for runs divided
// at the month edges. An undivided week- or month-class interval across a month edge is its own run
// (CoverageBuilder), so the edge tolerance below judges it exactly (D-14).
if (FitsBucketSize(run, bucket))
{
return true;
}
@@ -294,6 +291,53 @@ public static class CoverageEvaluator
return true;
}
/// <summary>
/// Whether a run's own rhythm places its amounts inside buckets of this size, without looking at where the
/// run lies: day-or-finer data for any size, week data for week buckets, and data divided at the local month
/// edges for month and year buckets (A-02, A-03). The first two tests of <see cref="Resolves"/>.
/// </summary>
private static bool FitsBucketSize(CoverageRun run, AnalysisBucket bucket) =>
(run.Resolution <= ResolutionClassifier.CoarsestResolving(bucket.Size)
&& (bucket.Size is BucketSize.Day or BucketSize.Week || run.Resolution <= ResolutionClass.Day))
|| (run.DividedAtMonths && bucket.Size is BucketSize.Month or BucketSize.Year);
/// <summary>
/// Whether a bucket that no run covers is still waiting to be measured rather than without data (A-41).
/// </summary>
/// <remarks>
/// A tank read by dipstick twice a year, or a burner whose hours are noted every few months, books nothing
/// between two readings: the amount of the stretch is only known once the next reading closes it. A period
/// that opened after the last reading therefore has no coverage at all, although the meter is neither silent
/// nor broken — its measurement is simply coarser than the question. Saying "no data" there contradicts the
/// coverage the same page reports, so such a bucket reads "only coarser data" (D-14), with the resolution of
/// the data before it and — like every unresolved bucket — without a number.
/// <para>
/// It holds only for data that cannot place an amount in a bucket of this size anyway
/// (<see cref="FitsBucketSize"/>): an hourly meter that went silent, or a monthly sheet whose months are
/// divided, books its buckets as it goes, so a bucket it does not cover really has none. And it holds only
/// within one interval of the run's class past the run (<see cref="ResolutionClassifier.LimitOf"/>, A-04) —
/// unbounded for data coarser than a month, whose next reading may be a year away, but not for a monthly
/// import asked about a day two years after it ended.
/// </para>
/// </remarks>
private static bool AwaitsMeasurement(CoverageRun run, AnalysisBucket bucket) =>
!FitsBucketSize(run, bucket) && bucket.To - run.To <= ResolutionClassifier.LimitOf(run.Resolution);
/// <summary>The meter's last covered stretch before <paramref name="bucket"/>, or null when it has none.</summary>
private static CoverageRun? Preceding(IReadOnlyList<CoverageRun> runs, AnalysisBucket bucket)
{
CoverageRun? best = null;
foreach (var run in runs)
{
if (!run.IsGap && run.To <= bucket.From && (best is null || run.To > best.To))
{
best = run;
}
}
return best;
}
private static BucketCoverage EvaluateCore(
AnalysisBucket bucket,
List<CoverageRun> covering,
@@ -301,7 +345,8 @@ public static class CoverageEvaluator
TimeZoneInfo zone,
bool checkResolution,
bool openingBalance,
DateTimeOffset? now)
DateTimeOffset? now,
CoverageRun? preceding = null)
{
var length = bucket.To - bucket.From;
if (length == TimeSpan.Zero)
@@ -314,13 +359,19 @@ public static class CoverageEvaluator
var covered = stretches.Aggregate(TimeSpan.Zero, (sum, s) => sum + s.Length);
var reasons = gaps.Select(g => g.Gap).Distinct().OrderBy(Significance).ToList();
ResolutionClass? resolution = covering.Count > 0 ? covering.Max(r => r.Resolution) : null;
// A bucket nothing covers is not always without data: a meter read every few months has not measured it
// *yet* (A-41). Then its status is the coarse resolution of the data around it, not "no data".
var awaited = covering.Count == 0 && checkResolution && reasons.Count == 0 && !openingBalance
&& preceding is { } last && AwaitsMeasurement(last, bucket) ? last.Resolution : (ResolutionClass?)null;
ResolutionClass? resolution = covering.Count > 0 ? covering.Max(r => r.Resolution) : awaited;
var unresolved = checkResolution && covering.Exists(r => !Resolves(r, bucket, zone));
var complete = covered >= length - CoverageTolerance || UpToDate(bucket, covering, gaps, stretches, covered, now);
var (status, issue) = (covering.Count, unresolved, openingBalance) switch
{
(0, _, true) => (BucketStatus.Partial, ValueIssue.OpeningBalance),
(0, _, false) when awaited is not null => (BucketStatus.Unresolved, ValueIssue.CoarseResolution),
(0, _, false) => (BucketStatus.Missing, reasons.Count > 0 ? IssueOf(reasons[0]) : ValueIssue.NoCoverage),
(_, true, _) => (BucketStatus.Unresolved, ValueIssue.CoarseResolution),
(_, false, true) => (BucketStatus.Partial, ValueIssue.OpeningBalance),
@@ -463,16 +514,35 @@ public static class CoverageEvaluator
private int _next;
/// <summary>The covering and gap runs overlapping <paramref name="bucket"/>; buckets must come by ascending start.</summary>
public (List<CoverageRun> Covering, List<CoverageRun> Gaps) Overlapping(AnalysisBucket bucket)
private CoverageRun? _preceding;
/// <summary>
/// The covering and gap runs overlapping <paramref name="bucket"/>, and the last covered stretch before it
/// (A-41); buckets must come by ascending start.
/// </summary>
public (List<CoverageRun> Covering, List<CoverageRun> Gaps, CoverageRun? Preceding) Overlapping(AnalysisBucket bucket)
{
while (_next < _runs.Count && _runs[_next].From < bucket.To)
{
_active.Add(_runs[_next++]);
}
// No later bucket starts before this one, so a run ending by its start is done for good.
_active.RemoveAll(r => r.To <= bucket.From);
// No later bucket starts before this one, so a run ending by its start is done for good — but it is
// still the coverage before every later bucket.
_active.RemoveAll(r =>
{
if (r.To > bucket.From)
{
return false;
}
if (!r.IsGap && (_preceding is null || r.To > _preceding.To))
{
_preceding = r;
}
return true;
});
var covering = new List<CoverageRun>();
var gaps = new List<CoverageRun>();
@@ -484,7 +554,7 @@ public static class CoverageEvaluator
}
}
return (covering, gaps);
return (covering, gaps, _preceding);
}
}
}
@@ -83,6 +83,55 @@ public static class ResolutionClassifier
_ => throw new ArgumentOutOfRangeException(nameof(size), size, "Only concrete bucket sizes have a resolution class."),
};
/// <summary>
/// The class the data a chart plots asks <see cref="BucketPlanner.Plan"/> to floor at (A-41): the coarsest
/// among <paramref name="runs"/>, with two corrections — a run divided at the local month edges counts as
/// <see cref="ResolutionClass.Month"/> whatever its length (A-03), and data coarser than a month asks for
/// years only when years can place every bit of it. Null when nothing covers the range.
/// </summary>
/// <remarks>
/// <see cref="ResolutionClass.Coarse"/> only says "longer than a local month". A meter read once a quarter, on
/// the quarter, books each interval inside one year: a chart of years holds every one of them whole and is
/// exactly the right answer. A tank dipped every autumn straddles a New Year as surely as it straddles every
/// month start, so year buckets leave it just as unresolved as month buckets do (<see cref="CoverageEvaluator"/>)
/// — while answering "last 12 months" with a single bar. Where one such interval is in the range, years earn
/// nothing, so the floor stops at the month and the unresolved buckets say why, with the toolbar and the empty
/// chart offering the coarser interval (D-14, A-28). It is one decision for the whole chart, because a chart
/// has one bucket size (D-05).
/// </remarks>
/// <param name="runs">The covering runs of everything plotted, over the range plotted; gap runs are ignored.</param>
/// <param name="zone">The instance zone, whose local years the runs are measured against.</param>
public static ResolutionClass? PlanningResolution(IEnumerable<CoverageRun> runs, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(runs);
ArgumentNullException.ThrowIfNull(zone);
ResolutionClass? coarsest = null;
var yearsPlaceEverything = true;
foreach (var run in runs)
{
if (run.IsGap)
{
continue;
}
var resolution = run.DividedAtMonths && run.Resolution > ResolutionClass.Month ? ResolutionClass.Month : run.Resolution;
if (resolution == ResolutionClass.Coarse && !WithinOneLocalYear(run, zone))
{
yearsPlaceEverything = false;
}
coarsest = coarsest is { } c && c >= resolution ? c : resolution;
}
return coarsest == ResolutionClass.Coarse && !yearsPlaceEverything ? ResolutionClass.Month : coarsest;
}
/// <summary>True when <c>[From, To)</c> lies inside a single local calendar year; an empty run trivially does.</summary>
private static bool WithinOneLocalYear(CoverageRun run, TimeZoneInfo zone) =>
run.To <= run.From
|| CalendarEdges.LocalDate(run.From, zone).Year == CalendarEdges.LocalDate(run.To.AddTicks(-1), zone).Year;
/// <summary>The coarser of two classes — the resolution of values that combine both (D-27).</summary>
public static ResolutionClass Coarsest(ResolutionClass a, ResolutionClass b) => a >= b ? a : b;
}
+4 -2
View File
@@ -11,8 +11,10 @@ namespace MeterVault.Core.Analysis;
/// A-06) — day up to 62 days, week up to 26 weeks, otherwise month — so "year to date" charts by month in
/// February as in November, and one URL renders the same way all year. It never goes finer than the
/// coarsest resolution any plotted series can resolve (<c>coarsestNeeded</c>): a monthly import asked by
/// day would only produce "unresolved" buckets. It then coarsens until the buckets that actually exist — up
/// to now — fit the caller's point limit, so centuries of history end up in years.
/// day would only produce "unresolved" buckets. What a plotted run asks for is
/// <see cref="Coverage.ResolutionClassifier.PlanningResolution"/>, which keeps data no bucket size can place
/// from coarsening the whole chart (A-41). It then coarsens until the buckets that actually exist — up to now —
/// fit the caller's point limit, so centuries of history end up in years.
/// </para>
/// <para>
/// An explicit size is honoured, even finer than the data (those buckets read as unresolved, D-14), but
+10 -21
View File
@@ -302,28 +302,17 @@ internal sealed class AnalysisRun
}
/// <summary>
/// The coarsest resolution among the data covering the period (D-05): auto never plans finer. A run divided at
/// month boundaries resolves months whatever its class.
/// The coarsest resolution among the data covering the period (D-05): auto never plans finer. What each run asks
/// for is <see cref="ResolutionClassifier.PlanningResolution"/> — a run divided at month boundaries resolves
/// months whatever its class (A-03), and a run coarser than a month only pushes the chart to years when years
/// can place it (A-41).
/// </summary>
private ResolutionClass? CoarsestNeeded()
{
ResolutionClass? coarsest = null;
foreach (var id in _dataLeaves)
{
foreach (var run in _leaves[id].CappedRuns(Now))
{
if (run.IsGap || run.From >= _period.To || run.To <= _period.From)
{
continue;
}
var resolution = run.DividedAtMonths && run.Resolution > ResolutionClass.Month ? ResolutionClass.Month : run.Resolution;
coarsest = coarsest is { } c && c >= resolution ? c : resolution;
}
}
return coarsest;
}
private ResolutionClass? CoarsestNeeded() =>
ResolutionClassifier.PlanningResolution(
_dataLeaves
.SelectMany(id => _leaves[id].CappedRuns(Now))
.Where(run => !run.IsGap && run.From < _period.To && run.To > _period.From),
_zone);
// ---------------------------------------------------------------- comparison and matched coverage
@@ -91,7 +91,7 @@ public sealed record RecordPage<T>(IReadOnlyList<T> Rows, RecordCursor? Next, in
/// </summary>
/// <param name="Events">Events in the range, newest first, at most the requested number.</param>
/// <param name="MoreEvents">True when the range holds more events than listed.</param>
/// <param name="TariffChanges">Applicable tariffs whose validity starts inside the range, oldest first.</param>
/// <param name="TariffChanges">Applicable tariffs whose validity starts inside the range, newest first (A-42).</param>
public sealed record MeterMarkers(IReadOnlyList<EventRow> Events, bool MoreEvents, IReadOnlyList<TariffRow> TariffChanges)
{
public static MeterMarkers None { get; } = new([], false, []);
@@ -200,7 +200,8 @@ public sealed class MeterDetailService
/// <summary>
/// The tariffs that can price the meter — its own, its energy type's and the global ones — by component, then
/// start date. All of them, not only those of a period: a price history is short.
/// scope, then start date with the newest first (A-42). All of them, not only those of a period: a price history
/// is short.
/// </summary>
public async Task<IReadOnlyList<TariffRow>> GetTariffsAsync(int meterId, CancellationToken cancellationToken = default)
{
@@ -213,14 +214,14 @@ public sealed class MeterDetailService
}
return await TariffsOf(db, meterId, typeId)
.OrderBy(t => t.Component).ThenBy(t => t.ScopeType).ThenBy(t => t.ValidFrom)
.OrderBy(t => t.Component).ThenBy(t => t.ScopeType).ThenByDescending(t => t.ValidFrom)
.Select(t => new TariffRow(t.Id, t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// The events and tariff changes inside <paramref name="range"/> (brief §7.2 contextual markers), at most
/// <paramref name="maxEvents"/> events.
/// <paramref name="maxEvents"/> events. Both lists are newest first (A-42), so the cap keeps the recent ones.
/// </summary>
/// <param name="meterId">The meter.</param>
/// <param name="range">The analysis range.</param>
@@ -248,7 +249,7 @@ public sealed class MeterDetailService
.ToListAsync(cancellationToken).ConfigureAwait(false);
var tariffs = await TariffsOf(db, meterId, typeId)
.Where(t => t.ValidFrom >= firstDay && t.ValidFrom <= lastDay)
.OrderBy(t => t.ValidFrom).ThenBy(t => t.Component)
.OrderByDescending(t => t.ValidFrom).ThenBy(t => t.Component)
.Take(maxEvents)
.Select(t => new TariffRow(t.Id, t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -220,8 +220,13 @@ public sealed record OverviewTypeFigures(
CostChange CostChange,
Freshness Freshness)
{
/// <summary>True when no measure has a value for the period.</summary>
public bool HasNoValues => Measures.All(m => m.Total.Value is null) && Cost?.Total.Cost is null;
/// <summary>
/// True when the type has nothing at all for the period: every measure is missing and nothing is charged. A
/// measure without a value that has a reason of its own — only coarser data, being prepared — is not "no data"
/// (A-41), and its own row states that reason instead.
/// </summary>
public bool HasNoValues =>
Measures.All(m => m.Total.Status == BucketStatus.Missing) && Cost?.Total.Cost is null;
}
/// <summary>What a row of the Overview's change table is.</summary>
@@ -336,6 +336,7 @@ public sealed class BucketPlannerTests
// A monthly import in a daily chart would be nothing but unresolved buckets.
var monthly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Month);
var weekly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Week);
var coarse = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Coarse);
var month = Assert.Single(monthly.Buckets);
@@ -335,6 +335,93 @@ public sealed class CoverageEvaluatorTests
Assert.Equal(0d, coverage.CoveredFraction);
}
// ---- Waiting for a measurement coarser than the bucket (A-41) ------------------------------------
[Fact]
public void Buckets_after_a_dipstick_wait_for_the_next_one_instead_of_reading_as_no_data()
{
// A heating-oil tank dipped on 5 October 2024 and 20 September 2025: one interval, coarser than a month.
// "Last 12 months" opens eleven days after the last dipstick, so nothing covers it — but the tank is
// neither silent nor broken, it is read twice a year. Every bucket says "only coarser data", with that
// resolution, and none of them claims a number.
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
var october = Evaluate(Month(2025, 10), dipsticks);
var august = Evaluate(Month(2026, 8), dipsticks);
Assert.Equal(BucketStatus.Unresolved, october.Status);
Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
Assert.Equal(ResolutionClass.Coarse, october.Resolution);
Assert.Equal(TimeSpan.Zero, october.Covered);
Assert.Null(october.ToValue(0, Provenance.None).Value);
Assert.Equal(BucketStatus.Unresolved, august.Status);
}
[Fact]
public void A_bucket_before_a_meters_first_reading_is_missing_not_waiting()
{
// Nothing precedes it: the tank had not been dipped once, so there is no measurement on its way.
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2024, 3), dipsticks).Status);
}
[Fact]
public void A_meter_that_books_its_own_buckets_still_reads_as_no_data_where_it_has_none()
{
// The rule is only for data too coarse for the bucket: an hourly source that went silent in March, and a
// monthly sheet that ends in March, both book their buckets as they go, so a bucket they do not cover
// really has no data.
var hourly = Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Hour);
var sheet = MonthLabels(2026, 1, 2026, 4);
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 4), hourly).Status);
Assert.Equal(BucketStatus.Missing, Evaluate(Day(2026, 4, 2), hourly).Status);
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 5), sheet).Status);
Assert.Equal(BucketStatus.Missing, Evaluate(Year(2027), sheet).Status);
}
[Fact]
public void A_monthly_sheet_waits_a_month_for_its_next_row_and_then_has_no_data()
{
// Monthly rows cannot fill a day bucket at all, so the days right after the sheet ends are unresolved —
// the April row will book them. Two years on, the sheet is not late but over (A-04, LimitOf).
var sheet = MonthLabels(2026, 1, 2026, 4);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Day(2026, 4, 2), sheet).Status);
Assert.Equal(ResolutionClass.Month, Evaluate(Day(2026, 4, 2), sheet).Resolution);
Assert.Equal(BucketStatus.Missing, Evaluate(Day(2028, 4, 2), sheet).Status);
}
[Fact]
public void A_known_hole_keeps_its_own_reason_rather_than_waiting()
{
// A gap run overlapping the bucket is a hole someone has to explain, not a measurement on its way.
CoverageRun[] runs =
[
Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse),
Gap(At(2025, 9, 20, 10), At(2026, 2, 1), CoverageGapReason.UnexplainedDecrease),
];
var coverage = Evaluate(Month(2025, 11), runs);
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
}
[Fact]
public void A_series_reads_the_waiting_buckets_exactly_as_one_by_one()
{
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
List<AnalysisBucket> months = [.. Enumerable.Range(0, 12).Select(i => Month(2025 + ((9 + i) / 12), (((9 + i) % 12) + 1)))];
var series = CoverageEvaluator.EvaluateSeries(months, [dipsticks], Berlin, null);
Assert.Equal(months.Select(m => Evaluate(m, dipsticks).Status), series.Select(c => c.Status));
Assert.All(series, c => Assert.Equal(BucketStatus.Unresolved, c.Status));
Assert.All(series, c => Assert.Equal(ResolutionClass.Coarse, c.Resolution));
}
[Fact]
public void Runs_that_only_touch_the_bucket_edges_do_not_cover_it()
{
@@ -92,6 +92,70 @@ public sealed class ResolutionClassifierTests
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()
{
@@ -117,6 +117,12 @@ public sealed class AnalysisComponentRenderTests
Assert.Contains("<th scope=\"row\" class=\"mv-row-label\">Feb</th>", html, StringComparison.Ordinal);
Assert.Contains(">0 kWh", html, StringComparison.Ordinal);
// A-42: the total above the rows, then March, February, January.
Assert.Equal(
["Total", "Mar", "Feb", "Jan"],
System.Text.RegularExpressions.Regex.Matches(html, "<th scope=\"row\" class=\"mv-row-label\">([^<]+)</th>")
.Select(m => m.Groups[1].Value));
Assert.Contains("mv-unknown\">—", html, StringComparison.Ordinal);
Assert.Contains("No data covers this period", html, StringComparison.Ordinal);
Assert.Contains("href=\"/drill/2\"", html, StringComparison.Ordinal);
@@ -286,6 +292,55 @@ public sealed class AnalysisComponentRenderTests
Assert.Contains("* Partial, estimated or not fully priced", some, StringComparison.Ordinal);
}
[Fact]
public async Task The_comparison_line_names_the_figure_it_is_about_when_a_page_shows_several()
{
// A-41: on one energy type page the tank's use shares no covered day with the year before while the burner
// beside it compares month by month. Unqualified, the line contradicts the card and the table under it.
var period = Range(D(2025, 10, 1), D(2026, 9, 30));
var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear), Now);
var alone = await RenderAsync<ComparisonSummary>("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
});
Assert.Contains("Not comparable: the two periods share no covered days", alone, StringComparison.Ordinal);
var named = await RenderAsync<ComparisonSummary>("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
[nameof(ComparisonSummary.Subject)] = "Total use",
});
Assert.Contains("Total use: not comparable — the two periods share no covered days", named, StringComparison.Ordinal);
Assert.DoesNotContain("Not comparable: the two", named, StringComparison.Ordinal);
var matched = await RenderAsync<ComparisonSummary>("de", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = Matched(D(2025, 10, 1), D(2026, 5, 1)),
[nameof(ComparisonSummary.Subject)] = "Laufzeit",
});
Assert.Contains("Laufzeit: Veränderung über den gemeinsam abgedeckten Zeitraum ", matched, StringComparison.Ordinal);
}
/// <summary>One matched stretch of the current period and the same stretch a year earlier.</summary>
private static MatchedCoverageResult Matched(DateOnly first, DateOnly last)
{
MatchedRange RangeOf(int years) => new(
new DateTimeOffset(first.AddYears(years).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
new DateTimeOffset(last.AddYears(years).AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
first.AddYears(years),
last.AddYears(years));
var piece = new MatchedPiece(RangeOf(0), RangeOf(-1));
return new MatchedCoverageResult(piece.Current, piece.Comparison, [piece]);
}
/// <summary>Renders a component and returns its HTML with entities decoded (the renderer encodes every non-ASCII letter).</summary>
private static async Task<string> RenderAsync<TComponent>(string culture, Dictionary<string, object?> parameters)
where TComponent : IComponent => WebUtility.HtmlDecode(await RenderRawAsync<TComponent>(culture, parameters));
@@ -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]
@@ -38,15 +38,16 @@ internal static class AnalysisUiTestData
public static BucketValue Missing() => BucketValue.Missing();
/// <summary>A physical meter's series in kWh with its total and, optionally, a comparison.</summary>
/// <summary>A physical meter's series in kWh (or <paramref name="unit"/>) with its total and, optionally, a comparison.</summary>
public static AnalysisSeries Series(
int meterId,
string name,
IReadOnlyList<BucketValue> values,
BucketValue? total = null,
QuantityKind kind = QuantityKind.Consumption,
SeriesComparison? comparison = null) =>
new(SeriesKey.ForMeter(meterId, 1, "kWh"), name, SeriesBasis.Physical, kind, "kWh", values, total ?? Available(values.Sum(v => v.Value ?? 0)), IsAdditive: true)
SeriesComparison? comparison = null,
string unit = "kWh") =>
new(SeriesKey.ForMeter(meterId, 1, unit), name, SeriesBasis.Physical, kind, unit, values, total ?? Available(values.Sum(v => v.Value ?? 0)), IsAdditive: true)
{
Comparison = comparison,
};
@@ -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));
}
@@ -171,25 +171,28 @@ public sealed class EnergyPageTests
// ------------------------------------------------------------------------------------------------ changes
[Fact]
public void Largest_changes_use_matched_values_and_skip_what_is_not_comparable()
{
var matched = new MatchedCoverageResult(
/// <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)))]);
AnalysisSeries With(int id, double current, double previous) =>
/// <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)),
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 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]
@@ -37,6 +37,7 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
Tariff[] tariffs =
[
Tariff(TariffScope.Meter, _meter, TariffComponent.UnitPrice, 1.2345, "EUR/m3"),
Tariff(TariffScope.Meter, _meter, TariffComponent.UnitPrice, 1.9999, "EUR/m3", new DateOnly(2023, 1, 1)),
Tariff(TariffScope.EnergyType, _type, TariffComponent.Bonus, 2.3456, "EUR"),
Tariff(TariffScope.EnergyType, _otherType, TariffComponent.UnitPrice, 9.8765, "EUR/kWh"),
];
@@ -67,9 +68,15 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
Assert.DoesNotContain("9.8765", scoped, StringComparison.Ordinal); // another type's price is not listed
Assert.Contains("Bonus, discount and tax tariffs are stored but not applied to costs yet.", scoped, StringComparison.Ordinal);
// A-42: inside a component the newest validity is listed first, so the price in force is the top row.
Assert.True(
scoped.IndexOf("1.9999", StringComparison.Ordinal) < scoped.IndexOf("1.2345", StringComparison.Ordinal),
"The 2023 price must be listed above the 2020 one.");
var all = WebUtility.HtmlDecode(await client.GetStringAsync(new Uri(TariffLinks.Path, UriKind.Relative)));
Assert.Contains("9.8765", all, StringComparison.Ordinal);
Assert.DoesNotContain("Tariffs that can price", all, StringComparison.Ordinal);
Assert.True(all.IndexOf("1.9999", StringComparison.Ordinal) < all.IndexOf("1.2345", StringComparison.Ordinal));
// The deep link of a missing price renders; its dialog opens only once the page is interactive.
var link = TariffLinks.New(TariffScope.Meter, _meter, TariffComponent.UnitPrice, new DateOnly(2027, 1, 1));
@@ -99,6 +106,6 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
Assert.Contains("Auswertungsdaten", deutsch, StringComparison.Ordinal);
}
private static Tariff Tariff(TariffScope scope, int id, TariffComponent component, double value, string unit) =>
new() { ScopeType = scope, ScopeId = id, Component = component, Value = value, Unit = unit, ValidFrom = new DateOnly(2020, 1, 1) };
private static Tariff Tariff(TariffScope scope, int id, TariffComponent component, double value, string unit, DateOnly? from = null) =>
new() { ScopeType = scope, ScopeId = id, Component = component, Value = value, Unit = unit, ValidFrom = from ?? new DateOnly(2020, 1, 1) };
}
@@ -202,13 +202,15 @@ public sealed class MeterDetailServiceTests(TimescaleFixture fx)
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
var other = await box.TypeAsync();
await box.TypePriceAsync(type, 0.30, D(2025, 1, 1));
await box.TypePriceAsync(type, 0.31, D(2025, 5, 1));
await box.TypePriceAsync(type, 0.32, D(2025, 7, 1));
await box.MeterPriceAsync(meter, 0.25, D(2025, 3, 1));
await box.TypePriceAsync(other, 0.99, D(2025, 3, 1));
await using (var db = fx.CreateContext())
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 2, 10), EventType = MeterEventType.Note, Notes = "before" });
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 5, 10), EventType = MeterEventType.Note, Notes = "inside" });
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 4, 10), EventType = MeterEventType.Note, Notes = "inside" });
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 5, 10), EventType = MeterEventType.Note, Notes = "later" });
await db.SaveChangesAsync();
}
@@ -216,16 +218,21 @@ public sealed class MeterDetailServiceTests(TimescaleFixture fx)
var markers = await service.GetMarkersAsync(
meter, new RecordRange(Midnight(2025, 3, 1), Midnight(2025, 7, 1)), D(2025, 3, 1), D(2025, 6, 30));
Assert.Equal("inside", Assert.Single(markers.Events).Notes);
// Both lists newest first (A-42): the page draws them as one dated list.
Assert.Equal(["later", "inside"], markers.Events.Select(e => e.Notes));
Assert.False(markers.MoreEvents);
var change = Assert.Single(markers.TariffChanges);
Assert.Equal((TariffScope.Meter, 0.25), (change.Scope, change.Value));
Assert.Equal([D(2025, 5, 1), D(2025, 3, 1)], markers.TariffChanges.Select(t => t.ValidFrom));
Assert.Equal((TariffScope.Meter, 0.25), (markers.TariffChanges[1].Scope, markers.TariffChanges[1].Value));
// The tariff list: the meter's own and its type's (and any global), never another type's.
// The tariff list: the meter's own and its type's (and any global), never another type's — newest first
// inside each scope, so the price that applies now is at the top (A-42).
var tariffs = await service.GetTariffsAsync(meter);
Assert.Contains(tariffs, t => t.Scope == TariffScope.Meter && t.ScopeId == meter);
Assert.Equal(2, tariffs.Count(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type));
Assert.Equal(3, tariffs.Count(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type));
Assert.DoesNotContain(tariffs, t => t.Scope == TariffScope.EnergyType && t.ScopeId == other);
Assert.Equal(
[D(2025, 7, 1), D(2025, 5, 1), D(2025, 1, 1)],
tariffs.Where(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type).Select(t => t.ValidFrom));
}
[Fact]
@@ -278,4 +278,35 @@ public sealed class MeterPageLogicTests
Assert.NotNull(MeterProjection.For(MonthSeries(1800, ResolutionClass.Month, start, Now.AddDays(-10)), ytd));
Assert.Null(MeterProjection.For(MonthSeries(1800, ResolutionClass.Month, start, Now.AddDays(-100)), ytd));
}
// -------------------------------------------------------------------------------------------------- markers
[Fact]
public void The_period_markers_are_one_dated_list_newest_first() => In("en", () =>
{
// A-42: events and price changes are drawn as one list, so they interleave by date instead of running down
// twice. On one day the event comes first, then the price that starts with it.
DateTimeOffset At(int month, int day) =>
new DateTimeOffset(2026, month, day, 12, 0, 0, TimeSpan.Zero).ToUniversalTime();
var markers = new MeterMarkers(
[
new EventRow(3, At(5, 20), MeterEventType.Delivery, 2000, null, null, "L", null, null),
new EventRow(2, At(3, 4), MeterEventType.Note, null, null, null, null, "new tenant", null),
],
MoreEvents: false,
[
new TariffRow(1, TariffScope.EnergyType, 3, TariffComponent.UnitPrice, 0.9, "EUR/L", D(2026, 3, 4), null),
new TariffRow(2, TariffScope.Meter, 7, TariffComponent.UnitPrice, 1.1, "EUR/L", D(2026, 1, 1), null),
]);
var list = MeterMarkerList.Of(markers, Berlin, "Heizöl");
Assert.Equal([D(2026, 5, 20), D(2026, 3, 4), D(2026, 3, 4), D(2026, 1, 1)], list.Select(m => m.Day));
Assert.Equal("Delivery: 2,000.0 L", list[0].Text);
Assert.Equal("Note: new tenant", list[1].Text); // the event of 4 March …
Assert.Contains("Heizöl", list[2].Text, StringComparison.Ordinal); // … then the type price starting that day
Assert.Contains("This meter", list[3].Text, StringComparison.Ordinal);
Assert.Empty(MeterMarkerList.Of(MeterMarkers.None, Berlin, "Heizöl"));
});
}