Analysis: read a rarely-read meter as coarse, not absent; newest rows first
ci / build-test (push) Successful in 2m41s
ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped a few times a year and its burner read every few months, which is exactly the shape the coverage rules had not been walked through. "No data" for data that exists. A tank books nothing until the next dipstick closes the interval, so the stretch after the last dipstick is covered by no run at all, and a bucket no run covers was reported missing. The burner, whose run reaches into the window, said "only coarser data" -- the honest answer -- so one card claimed there was nothing while the coverage panel beside it listed years of data. A bucket that no run covers, no gap overlaps and no opening balance explains now reports the meter's resolution when its preceding coverage is within one interval of its own class: it is not silent, it is read rarely. A meter that does book its own buckets and stops -- a dead hourly source, a sheet asked about a later month -- still reads missing. Auto answering twelve months with one bar. Coarse only means "longer than a month", so a dipstick taken each autumn straddles a New Year as surely as a month start: coarsening the chart to years bought nothing and cost every point. The planning resolution now caps coarse at month when a run crosses a local year edge, and a series that cannot resolve the natural size no longer coarsens the whole chart -- it is drawn at that size with its buckets marked, which the chart and table already explain. A page contradicting itself. The comparison line above the ranking was fed the leading measure's matched coverage but worded as if it spoke for the page, directly above a burner row that did compare. It now names the figure it is about. Alongside: the "largest changes" ranking no longer drops a meter whose change is not comparable. It ranks what can be ranked, then lists the rest with their values and the reason -- the tank had been vanishing from its own energy type. And every dated table now reads newest first, as lists are read; charts stay chronological left to right, and the CSV export stays ascending for spreadsheets. A-41 to A-43 in the note record the three rules.
This commit is contained in:
@@ -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 "*".
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-2">@S.EnergyView_LargestChangesNote</MudText>
|
||||
@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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
/// <summary>The coverage of a whole January that both periods share.</summary>
|
||||
private static readonly MatchedCoverageResult Matched = new(
|
||||
new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)),
|
||||
new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)),
|
||||
[new MatchedPiece(new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)), new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)))]);
|
||||
|
||||
/// <summary>A meter that changed from <paramref name="previous"/> to <paramref name="current"/> over that coverage.</summary>
|
||||
private static AnalysisSeries Changed(int id, double current, double previous) =>
|
||||
Series(id, $"M{id}", [Available(current)]) with
|
||||
{
|
||||
Comparison = new SeriesComparison([Available(previous)], Available(previous), Matched, current, previous, Change.Between(current, previous)),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Largest_changes_use_matched_values_and_skip_what_is_not_comparable()
|
||||
{
|
||||
var matched = new MatchedCoverageResult(
|
||||
new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)),
|
||||
new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)),
|
||||
[new MatchedPiece(new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)), new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)))]);
|
||||
AnalysisSeries With(int id, double current, double previous) =>
|
||||
Series(id, $"M{id}", [Available(current)]) with
|
||||
{
|
||||
Comparison = new SeriesComparison([Available(previous)], Available(previous), matched, current, previous, Change.Between(current, previous)),
|
||||
};
|
||||
|
||||
var notComparable = Series(9, "Not comparable", [Available(5)]) with
|
||||
{
|
||||
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||||
};
|
||||
|
||||
var changes = MeterChanges.Largest([With(1, 100, 90), With(2, 50, 150), With(3, 10, 10), notComparable], 2);
|
||||
var changes = MeterChanges.Largest([Changed(1, 100, 90), Changed(2, 50, 150), Changed(3, 10, 10), notComparable], 2);
|
||||
|
||||
Assert.Equal([2, 1], changes.Select(c => c.Series.MeterId!.Value));
|
||||
Assert.Equal(-100, changes[0].Change.Absolute!.Value, 6);
|
||||
@@ -197,6 +200,94 @@ public sealed class EnergyPageTests
|
||||
Assert.Equal(150, changes[0].Previous, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_meter_with_data_but_no_comparable_change_is_listed_with_its_value_and_its_reason() => In("en", () =>
|
||||
{
|
||||
// The reported case (A-43): a Heizöl type whose burner has hours in both periods and whose tank was read by
|
||||
// dipstick only in this one. The burner ranks; the tank must still be on the page with its litres and the
|
||||
// status that says why there is no change — not silently dropped.
|
||||
var burner = Changed(8, 929, 986) with { Name = "Brenner" };
|
||||
var tank = Series(7, "Öltank", [Available(2100)], total: Available(2100), unit: "L") with
|
||||
{
|
||||
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||||
};
|
||||
|
||||
var list = MeterChanges.Of([burner, tank], 6);
|
||||
|
||||
Assert.Equal([8], list.Ranked.Select(c => c.Series.MeterId!.Value));
|
||||
var row = Assert.Single(list.Rest);
|
||||
Assert.Equal(7, row.Series.MeterId);
|
||||
Assert.Equal(2100, row.Current.Value);
|
||||
Assert.Equal(BucketStatus.Missing, row.Comparison.Status);
|
||||
Assert.False(row.Change.IsAvailable);
|
||||
|
||||
// The reason is the reader's own wording, not new prose: the comparison side simply has no data.
|
||||
Assert.Equal("No data", FigureText.Of(row.Comparison).Status);
|
||||
Assert.False(row.SharesNoDays);
|
||||
Assert.Equal(2, list.Shown);
|
||||
Assert.Equal(0, list.Hidden);
|
||||
Assert.False(list.IsEmpty);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void A_meter_without_data_in_either_period_is_not_listed_at_all() => In("en", () =>
|
||||
{
|
||||
var silent = Series(4, "Never read", [Missing()], total: Missing()) with
|
||||
{
|
||||
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||||
};
|
||||
var onlyLastYear = Series(5, "Retired", [Missing()], total: Missing()) with
|
||||
{
|
||||
Comparison = new SeriesComparison([Available(40)], Available(40), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||||
};
|
||||
var coarser = Series(6, "Monthly only", [Missing()], total: new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution));
|
||||
|
||||
var list = MeterChanges.Of([silent, onlyLastYear, coarser], 6);
|
||||
|
||||
Assert.Empty(list.Ranked);
|
||||
Assert.Equal([6, 5], list.Rest.Select(r => r.Series.MeterId!.Value)); // by name: "Monthly only", "Retired"
|
||||
Assert.Equal("Only coarser data", FigureText.Of(list.Rest[0].Current).Status);
|
||||
Assert.Equal(40, list.Rest[1].Comparison.Value);
|
||||
Assert.DoesNotContain(list.Rest, r => r.Series.MeterId == 4);
|
||||
|
||||
// Nothing at all to say: the panel is empty, and the page says so in one line instead of an empty table.
|
||||
Assert.True(MeterChanges.Of([silent], 6).IsEmpty);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void Two_known_values_over_no_shared_days_are_listed_as_not_comparable()
|
||||
{
|
||||
var series = Series(7, "Öltank", [Available(2100)], total: Available(2100), unit: "L") with
|
||||
{
|
||||
Comparison = new SeriesComparison(
|
||||
[Available(1800)], Available(1800), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||||
};
|
||||
|
||||
var row = Assert.Single(MeterChanges.Of([series], 6).Rest);
|
||||
|
||||
Assert.True(row.SharesNoDays);
|
||||
Assert.Equal((2100d, 1800d), (row.Current.Value, row.Comparison.Value));
|
||||
Assert.False(row.Change.IsAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Neither_list_crowds_the_other_out_and_what_is_left_over_is_counted()
|
||||
{
|
||||
AnalysisSeries Quiet(int id) => Series(id, $"Q{id}", [Available(id)], total: Available(id)) with
|
||||
{
|
||||
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||||
};
|
||||
|
||||
var list = MeterChanges.Of([.. Enumerable.Range(1, 5).Select(i => Changed(i, i * 10, i)), .. Enumerable.Range(10, 5).Select(Quiet)], 2);
|
||||
|
||||
// Two ranked and two unranked rows: a long ranking never takes the room of the meters that have no change,
|
||||
// and the meters it pushed past its own cap queue behind them.
|
||||
Assert.Equal([5, 4], list.Ranked.Select(c => c.Series.MeterId!.Value));
|
||||
Assert.Equal([10, 11], list.Rest.Select(r => r.Series.MeterId!.Value));
|
||||
Assert.Equal(6, list.Hidden);
|
||||
Assert.Equal(4, list.Shown);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ flow words
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user