Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s

The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
This commit is contained in:
Florian Schmidt
2026-09-20 10:29:13 +02:00
parent c0f52dbb6f
commit 8940ef25c3
384 changed files with 82753 additions and 4518 deletions
+570
View File
@@ -0,0 +1,570 @@
using System.Globalization;
using MeterVault.App.Localization;
using MeterVault.App.Theme;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Infrastructure.Analysis;
using MudBlazor;
using MudBlazor.Utilities;
namespace MeterVault.App.Analysis;
/// <summary>How a chart series is drawn.</summary>
public enum ChartSeriesStyle
{
Bar,
Line,
}
/// <summary>Why a chart value has no number (brief §4.3): the chart words an empty plot by it, never as "no data" alone.</summary>
public enum ChartGap
{
/// <summary>The value is known.</summary>
None,
/// <summary>No data, a calculation that cannot be evaluated, data being prepared.</summary>
NoData,
/// <summary>The data exists only at a coarser resolution than the bucket (D-14 unresolved).</summary>
Unresolved,
/// <summary>A cost whose quantities are known but whose price is not (no tariff, a tariff gap, a unit mismatch, D-38).</summary>
NotPriced,
}
/// <summary>
/// One value as the chart draws it: the number (null for an unknown bucket, which is a gap — never a zero), whether it is
/// qualified (partial, estimated, not fully priced), and the words the tooltip adds to it.
/// </summary>
public sealed record ChartValue(double? Value, bool IsQualified, string? Note)
{
/// <summary>Why there is no number; <see cref="ChartGap.None"/> when there is one.</summary>
public ChartGap Gap { get; init; }
/// <summary>The status in words ("Only coarser data", "Not priced (no tariff)").</summary>
public string? Status { get; init; }
/// <summary>A quantity bucket (<see cref="FigureText.Of(BucketValue, Func{int, string?}?)"/>).</summary>
public static ChartValue Of(BucketValue value, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(value);
var status = FigureText.Of(value, meterName);
var gap = status.IsKnown ? ChartGap.None : value.Status == BucketStatus.Unresolved ? ChartGap.Unresolved : ChartGap.NoData;
return new ChartValue(status.IsKnown ? value.Value : null, status.IsQualified, status.IsQualified ? status.Full : null)
{
Gap = gap,
Status = status.Status,
};
}
/// <summary>A cost figure (<see cref="FigureText.Of(CostAmount)"/>).</summary>
public static ChartValue Of(CostAmount amount)
{
ArgumentNullException.ThrowIfNull(amount);
var status = FigureText.Of(amount);
var gap = status.IsKnown ? ChartGap.None
: amount.Status is CostStatus.NotPriced or CostStatus.PriceGap or CostStatus.UnitMismatch ? ChartGap.NotPriced
: amount.Availability == BucketStatus.Unresolved ? ChartGap.Unresolved
: ChartGap.NoData;
return new ChartValue(status.IsKnown ? amount.Cost : null, status.IsQualified, status.IsQualified ? status.Full : null)
{
Gap = gap,
Status = status.Status,
};
}
}
/// <summary>Why a chart has nothing to draw (<see cref="AnalysisChartPlan.EmptyReason"/>).</summary>
public enum ChartEmptyReason
{
/// <summary>Something can be drawn.</summary>
None,
/// <summary>No value is known: no data, or nothing that can be evaluated.</summary>
NoData,
/// <summary>The data is only resolved coarser than the buckets (a monthly import in days): a coarser interval shows it.</summary>
Unresolved,
/// <summary>The quantities are known but not priced: the cost is unavailable until a tariff covers it.</summary>
NotPriced,
}
/// <summary>
/// A series the analysis chart draws (D-49, brief §8): a stable key, a display name (user data is never translated), the
/// unit or currency its values are in, one value per bucket of the plan, bar or line, and whether it is the comparison
/// overlay of another series.
/// </summary>
/// <remarks>
/// A comparison overlay's values are paired with the current buckets by index (<see cref="BucketPair"/>, A-10): value i
/// belongs to the image of bucket i. It shares the colour of <see cref="BaseKey"/> and is drawn dashed (a line) or
/// faded (bars), so the pairing is readable without colour.
/// </remarks>
public sealed record AnalysisChartSeries
{
/// <summary>A quantity series.</summary>
/// <param name="key">A stable key (<see cref="SeriesKey.Id"/>, or any invariant token).</param>
/// <param name="name">The name shown in the legend and tooltip.</param>
/// <param name="unit">The normalized unit of every value (D-20).</param>
/// <param name="values">One value per bucket.</param>
public AnalysisChartSeries(string key, string name, string? unit, IReadOnlyList<BucketValue> values)
: this(key, name, unit, values, null)
{
}
/// <summary>A quantity series whose derived values name the meter they miss (<see cref="FigureText.Of(BucketValue, Func{int, string?}?)"/>).</summary>
/// <param name="key">A stable key.</param>
/// <param name="name">The name shown in the legend and tooltip.</param>
/// <param name="unit">The normalized unit of every value (D-20).</param>
/// <param name="values">One value per bucket.</param>
/// <param name="meterName">Names a meter id a value's dependency path ends at; "#id" without it.</param>
public AnalysisChartSeries(string key, string name, string? unit, IReadOnlyList<BucketValue> values, Func<int, string?>? meterName)
: this(key, name, unit, null, [.. (values ?? throw new ArgumentNullException(nameof(values))).Select(v => ChartValue.Of(v, meterName))])
{
}
private AnalysisChartSeries(string key, string name, string? unit, string? currency, IReadOnlyList<ChartValue> values)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(name);
Key = key;
Name = name;
Unit = unit;
Currency = currency;
Values = values;
}
public string Key { get; init; }
public string Name { get; init; }
/// <summary>The quantity unit; null for money.</summary>
public string? Unit { get; init; }
/// <summary>The ISO currency code when the values are money (D-43).</summary>
public string? Currency { get; init; }
public IReadOnlyList<ChartValue> Values { get; init; }
public ChartSeriesStyle Style { get; init; } = ChartSeriesStyle.Bar;
/// <summary>True for the comparison overlay of another series.</summary>
public bool IsComparison { get; init; }
/// <summary>For an overlay, the key of the series it compares; it takes that series' colour.</summary>
public string? BaseKey { get; init; }
/// <summary>True when the values are money.</summary>
public bool IsMoney => Currency is not null;
/// <summary>What the axis of this series is labelled with: the currency symbol for money, else the unit.</summary>
public string AxisUnit => Currency is { } currency ? Format.CurrencySymbol(currency) : Unit?.Trim() ?? string.Empty;
/// <summary>A cost series: one figure per bucket, in <paramref name="currency"/>.</summary>
public static AnalysisChartSeries ForCost(string key, string name, string currency, IReadOnlyList<CostAmount> amounts)
{
ArgumentNullException.ThrowIfNull(amounts);
ArgumentException.ThrowIfNullOrWhiteSpace(currency);
return new AnalysisChartSeries(key, name, null, currency, [.. amounts.Select(ChartValue.Of)]);
}
/// <summary>
/// A reader series (a meter or a measure total). <paramref name="name"/> defaults to the meter's name, or for a
/// measure to the measure's wording.
/// </summary>
/// <param name="series">The series.</param>
/// <param name="name">The legend name; the meter's name or the measure's wording by default.</param>
/// <param name="style">Bars by default.</param>
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
public static AnalysisChartSeries ForSeries(
AnalysisSeries series, string? name = null, ChartSeriesStyle style = ChartSeriesStyle.Bar, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(series);
return new AnalysisChartSeries(series.Key.Id, name ?? NameOf(series), series.Unit, series.Values, meterName) { Style = style };
}
/// <summary>
/// The comparison overlay of a reader series (<see cref="AnalysisSeries.Comparison"/>), paired with its buckets by
/// index; null when no comparison was read.
/// </summary>
/// <param name="series">The series.</param>
/// <param name="name">The overlay's name, e.g. <see cref="ComparisonName"/>.</param>
/// <param name="style">Line by default: a dashed line over the bars.</param>
/// <param name="meterName">Names the meter a derived value misses; "#id" without it.</param>
public static AnalysisChartSeries? ComparisonOf(
AnalysisSeries series, string name, ChartSeriesStyle style = ChartSeriesStyle.Line, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(series);
return series.Comparison is { } comparison
? new AnalysisChartSeries(series.Key.Id + ":cmp", name, series.Unit, comparison.Values, meterName)
{
Style = style,
IsComparison = true,
BaseKey = series.Key.Id,
}
: null;
}
/// <summary>The comparison overlay of a cost series, priced in the paired buckets (<see cref="CostComparisonRequest"/>).</summary>
public static AnalysisChartSeries ComparisonForCost(
string baseKey, string name, string currency, IReadOnlyList<CostAmount> amounts, ChartSeriesStyle style = ChartSeriesStyle.Line) =>
ForCost(baseKey + ":cmp", name, currency, amounts) with { Style = style, IsComparison = true, BaseKey = baseKey };
/// <summary>"Haus (same period last year)": a series name with the comparison it shows.</summary>
public static string ComparisonName(string name, ComparisonRequest comparison)
{
ArgumentNullException.ThrowIfNull(comparison);
return name + " (" + comparison.Display() + ")";
}
/// <summary>The name of a reader series: the meter's name, or the measure's wording for a total.</summary>
public static string NameOf(AnalysisSeries series)
{
ArgumentNullException.ThrowIfNull(series);
return series.Name.Length > 0 || series.Key.Measure is not { } measure ? series.Name : measure.Display();
}
}
/// <summary>
/// The chart colours of one theme mode, taken from the MudBlazor palette (D-49): the series hues in a fixed order, a
/// muted hue for overlays without a base, and the text, grid and zero-line colours. Colour follows the series, in the
/// order the series are given, so a meter keeps its hue when others are added after it.
/// </summary>
/// <remarks>
/// The order — primary, secondary, info, then error, warning and success for a fourth to sixth meter — is the palette
/// order whose neighbours stay distinguishable under protan and deutan vision (checked with an OKLab ΔE validator: ≥ 9.5
/// in dark mode, ≥ 6.3 in light mode, where the legend and the table are the secondary encoding).
/// </remarks>
public sealed record ChartPalette(bool IsDark, IReadOnlyList<string> Series, string Muted, string Text, string Grid, string Baseline, string Surface)
{
/// <summary>The colours of the light or dark palette of <see cref="MeterVaultTheme"/>.</summary>
public static ChartPalette For(bool isDark)
{
Palette palette = isDark ? MeterVaultTheme.Instance.PaletteDark : MeterVaultTheme.Instance.PaletteLight;
return new ChartPalette(
isDark,
[Hex(palette.Primary), Hex(palette.Secondary), Hex(palette.Info), Hex(palette.Error), Hex(palette.Warning), Hex(palette.Success)],
palette.GrayDefault,
Rgba(palette.TextSecondary),
Rgba(palette.LinesDefault),
Rgba(palette.TextSecondary),
Hex(palette.Surface));
}
/// <summary>The hue of the <paramref name="index"/>-th series (0-based).</summary>
public string SeriesColor(int index) => Series[((index % Series.Count) + Series.Count) % Series.Count];
/// <summary><c>#RRGGBB</c>: the chart library does its own colour arithmetic and expects plain hex.</summary>
public static string Hex(MudColor color)
{
ArgumentNullException.ThrowIfNull(color);
return string.Create(CultureInfo.InvariantCulture, $"#{color.R:X2}{color.G:X2}{color.B:X2}");
}
/// <summary><c>rgba(r,g,b,a)</c> with the colour's own alpha.</summary>
public static string Rgba(MudColor color)
{
ArgumentNullException.ThrowIfNull(color);
return string.Create(CultureInfo.InvariantCulture, $"rgba({color.R},{color.G},{color.B},{Math.Round(color.APercentage, 3)})");
}
/// <summary>A <c>#RRGGBB</c> colour at <paramref name="alpha"/> as <c>rgba(…)</c>; anything else is returned unchanged.</summary>
public static string WithAlpha(string hex, double alpha)
{
ArgumentNullException.ThrowIfNull(hex);
if (hex.Length != 7 || hex[0] != '#'
|| !int.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rgb))
{
return hex;
}
var a = Math.Clamp(alpha, 0, 1);
return string.Create(CultureInfo.InvariantCulture, $"rgba({(rgb >> 16) & 0xFF},{(rgb >> 8) & 0xFF},{rgb & 0xFF},{Math.Round(a, 3)})");
}
}
/// <summary>
/// One point of a chart series, as the chart library receives it: the bucket's index and label, the value (null for a
/// gap), its fill (faded when qualified) and the tooltip text, formatted here with the reader's culture, unit and
/// currency and the status in words.
/// </summary>
public sealed record ChartPoint(int Index, string Label, decimal? Value, string? FillColor, string Tooltip, bool IsQualified);
/// <summary>One drawn series of a <see cref="ChartPanel"/>.</summary>
public sealed record ChartPanelSeries(
string Key,
string Name,
ChartSeriesStyle Style,
bool IsComparison,
string Color,
int StrokeWidth,
int DashSpace,
IReadOnlyList<ChartPoint> Points);
/// <summary>
/// One chart with one y-axis: the series of one unit or currency. Series of different units are never drawn against
/// two scales on one plot; they get a panel each.
/// </summary>
/// <param name="Unit">The axis unit ("kWh", "€"); empty when the values have none.</param>
/// <param name="HasNegative">A value is below zero: the zero line is drawn.</param>
/// <param name="HasPositive">A value is above zero.</param>
/// <param name="HasMarked">
/// A current (not comparison) point has a value that is qualified (partial, estimated, not fully priced): the note under
/// the chart explains <see cref="AnalysisChartPlan.Marker"/>.
/// </param>
/// <param name="HasValues">At least one point has a value.</param>
/// <param name="Labels">
/// The panel's axis labels: the bucket labels, with <see cref="AnalysisChartPlan.Marker"/> where one of the panel's own
/// current series has a qualified value and <see cref="AnalysisChartPlan.GapMarker"/> where one has none — a gap in the
/// cost panel does not mark the quantity panel's months.
/// </param>
/// <param name="HasGaps">A current point has no value: the note under the chart explains <see cref="AnalysisChartPlan.GapMarker"/>.</param>
public sealed record ChartPanel(
string Unit,
IReadOnlyList<ChartPanelSeries> Series,
bool HasNegative,
bool HasPositive,
bool HasMarked,
bool HasValues,
IReadOnlyList<string> Labels,
bool HasGaps = false);
/// <summary>
/// What the analysis chart draws (D-49): the bucket labels, which buckets are marked as qualified, and the panels —
/// computed without the chart library, so the rules are testable: unknown values stay gaps, qualified buckets are marked
/// in the label (not by colour alone), overlays pair by index, labels carry the year across years.
/// </summary>
/// <param name="Labels">One label per bucket, unmarked (each panel marks its own, <see cref="ChartPanel.Labels"/>).</param>
/// <param name="Marked">Per bucket: some current series is qualified there.</param>
/// <param name="Panels">One per unit, in the order the units first appear.</param>
public sealed record AnalysisChartPlan(IReadOnlyList<string> Labels, IReadOnlyList<bool> Marked, IReadOnlyList<ChartPanel> Panels)
{
/// <summary>The mark added to the label of a bucket with a qualified value; the note under the chart explains it.</summary>
public const string Marker = " *";
/// <summary>
/// The mark added to the label of a bucket without a value (no data, not priced, only coarser data): it is a gap, not
/// a zero — a true zero is drawn on the baseline — and the note under the chart says so.
/// </summary>
public const string GapMarker = " ";
/// <summary>The bar outline: a true zero is drawn as this line on the baseline, a gap draws nothing.</summary>
public const int BarStrokeWidth = 2;
/// <summary>Why nothing can be drawn; <see cref="ChartEmptyReason.None"/> when something can.</summary>
public ChartEmptyReason EmptyReason { get; init; }
/// <summary>For <see cref="ChartEmptyReason.NotPriced"/>: the cost's status in words ("Not priced (no tariff)").</summary>
public string? EmptyStatus { get; init; }
/// <summary>The fill alpha of a qualified bar.</summary>
public const double QualifiedAlpha = 0.45;
/// <summary>The fill alpha of a comparison bar.</summary>
public const double ComparisonAlpha = 0.4;
/// <summary>True when anything can be drawn.</summary>
public bool HasValues => Panels.Any(p => p.HasValues);
/// <summary>Plans the chart.</summary>
/// <param name="buckets">The buckets of the plan (<see cref="BucketPlan.Buckets"/>), oldest first.</param>
/// <param name="series">The series; values beyond the buckets are ignored, missing ones are gaps.</param>
/// <param name="palette">The theme's colours.</param>
/// <param name="pairs">The comparison buckets paired with <paramref name="buckets"/> (A-10), to name an overlay's own bucket in its tooltip.</param>
public static AnalysisChartPlan Build(
IReadOnlyList<AnalysisBucket> buckets,
IReadOnlyList<AnalysisChartSeries> series,
ChartPalette palette,
IReadOnlyList<BucketPair>? pairs = null)
{
ArgumentNullException.ThrowIfNull(buckets);
ArgumentNullException.ThrowIfNull(series);
ArgumentNullException.ThrowIfNull(palette);
var labels = BucketLabels(buckets);
var marked = new bool[buckets.Count];
foreach (var current in series.Where(s => !s.IsComparison))
{
for (var i = 0; i < buckets.Count; i++)
{
marked[i] |= ValueAt(current, i).IsQualified;
}
}
// Colour follows the series in the order given, never its rank; an overlay takes its base's colour.
var colours = new Dictionary<string, string>(StringComparer.Ordinal);
var next = 0;
foreach (var current in series.Where(s => !s.IsComparison))
{
if (!colours.ContainsKey(current.Key))
{
colours[current.Key] = palette.SeriesColor(next++);
}
}
var names = UniqueNames(series);
var panels = new List<ChartPanel>();
foreach (var group in series.Select((s, i) => (Series: s, Name: names[i])).GroupBy(x => x.Series.AxisUnit, StringComparer.Ordinal))
{
// A value that is known but qualified gets "*", a bucket without a value "": the reader tells a partial month
// from an empty one without colour, and a true zero carries no mark at all.
var shown = labels
.Select((label, i) =>
{
var current = group.Where(x => !x.Series.IsComparison).Select(x => ValueAt(x.Series, i)).ToList();
var mark = (current.Any(v => v.Value is not null && v.IsQualified) ? Marker : string.Empty)
+ (current.Any(v => v.Value is null) ? GapMarker : string.Empty);
return label + mark;
})
.ToList();
var drawn = new List<ChartPanelSeries>();
bool negative = false, positive = false, markedHere = false, gapsHere = false, values = false;
foreach (var (item, name) in group)
{
var colour = item.IsComparison
? item.BaseKey is { } baseKey && colours.TryGetValue(baseKey, out var baseColour) ? baseColour : palette.Muted
: colours[item.Key];
var points = new List<ChartPoint>(buckets.Count);
for (var i = 0; i < buckets.Count; i++)
{
var value = ValueAt(item, i);
var number = ToDecimal(value.Value);
negative |= number < 0;
positive |= number > 0;
values |= number is not null;
markedHere |= !item.IsComparison && value.IsQualified && number is not null;
gapsHere |= !item.IsComparison && number is null;
var pairLabel = item.IsComparison && pairs is not null && i < pairs.Count
? Format.BucketLabel(pairs[i].Comparison, includeYear: true)
: null;
points.Add(new ChartPoint(i, shown[i], number, FillOf(item, value, colour), TooltipOf(item, value, pairLabel), value.IsQualified));
}
// Bars are outlined in their colour, so a true zero is a line on the baseline — an actual point (brief §4.3)
// — while a gap draws nothing. An overlay's outline is thinner, like its fill is fainter.
var line = item.Style == ChartSeriesStyle.Line;
var stroke = line ? 2 : item.IsComparison ? 1 : BarStrokeWidth;
drawn.Add(new ChartPanelSeries(
item.Key, name, item.Style, item.IsComparison, colour, stroke, line && item.IsComparison ? 5 : 0, points));
}
panels.Add(new ChartPanel(group.Key, drawn, negative, positive, markedHere, values, shown, gapsHere));
}
var plan = new AnalysisChartPlan(labels, marked, panels);
if (plan.HasValues)
{
return plan;
}
// Nothing to draw: say why. A price that is missing is the reason when there is one — the quantities are there —
// then data that is only coarser than the buckets; otherwise there is no data.
var gaps = series.Where(s => !s.IsComparison)
.SelectMany(s => Enumerable.Range(0, buckets.Count).Select(i => ValueAt(s, i)))
.ToList();
var notPriced = gaps.FirstOrDefault(v => v.Gap == ChartGap.NotPriced);
var reason = notPriced is not null ? ChartEmptyReason.NotPriced
: gaps.Any(v => v.Gap == ChartGap.Unresolved) ? ChartEmptyReason.Unresolved
: ChartEmptyReason.NoData;
return plan with { EmptyReason = reason, EmptyStatus = notPriced?.Status };
}
/// <summary>
/// The axis label of each bucket (<see cref="Format.BucketLabel"/>), with the year across years
/// (<see cref="Format.SpansYears(IReadOnlyList{AnalysisBucket})"/>). Labels are the chart's categories, so they are
/// kept distinct: should two still collide, both get their year, and a remaining duplicate its position.
/// </summary>
public static IReadOnlyList<string> BucketLabels(IReadOnlyList<AnalysisBucket> buckets)
{
ArgumentNullException.ThrowIfNull(buckets);
var labels = buckets.Select(b => Format.BucketLabel(b, Format.SpansYears(buckets))).ToList();
if (labels.Distinct(StringComparer.Ordinal).Count() != labels.Count)
{
labels = [.. buckets.Select(b => Format.BucketLabel(b, includeYear: true))];
}
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < labels.Count; i++)
{
if (!seen.Add(labels[i]))
{
labels[i] = labels[i] + " (" + (i + 1).ToString(CultureInfo.CurrentCulture) + ")";
seen.Add(labels[i]);
}
}
return labels;
}
/// <summary>The value at a bucket; a series shorter than the plan is unknown there, never zero.</summary>
private static ChartValue ValueAt(AnalysisChartSeries series, int index) =>
index < series.Values.Count
? series.Values[index]
: new ChartValue(null, true, BucketStatus.Missing.Display()) { Gap = ChartGap.NoData, Status = BucketStatus.Missing.Display() };
private static decimal? ToDecimal(double? value) =>
value is { } number && double.IsFinite(number) && Math.Abs(number) < 7.9e27 ? (decimal)number : null;
private static string? FillOf(AnalysisChartSeries series, ChartValue value, string colour)
{
if (series.Style != ChartSeriesStyle.Bar)
{
return null;
}
if (series.IsComparison)
{
return ChartPalette.WithAlpha(colour, value.IsQualified ? ComparisonAlpha / 2 : ComparisonAlpha);
}
return value.IsQualified ? ChartPalette.WithAlpha(colour, QualifiedAlpha) : colour;
}
private static string TooltipOf(AnalysisChartSeries series, ChartValue value, string? pairLabel)
{
var text = series.IsMoney ? Format.Money(value.Value, series.Currency) : Format.Quantity(value.Value, series.Unit);
if (pairLabel is not null)
{
text = pairLabel + ": " + text;
}
return value.Note is { Length: > 0 } note ? text + " · " + note : text;
}
/// <summary>
/// Series names made distinct (the chart library keys series by name): two meters may share a name, which is user
/// data; the second gets its position.
/// </summary>
private static List<string> UniqueNames(IReadOnlyList<AnalysisChartSeries> series)
{
var names = new List<string>(series.Count);
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < series.Count; i++)
{
var name = string.IsNullOrWhiteSpace(series[i].Name) ? series[i].Key : series[i].Name;
if (!seen.Add(name))
{
name = name + " (" + (i + 1).ToString(CultureInfo.CurrentCulture) + ")";
seen.Add(name);
}
names.Add(name);
}
return names;
}
}
+168
View File
@@ -0,0 +1,168 @@
using System.Globalization;
using System.Text.Json;
using ApexCharts;
namespace MeterVault.App.Analysis;
/// <summary>
/// The chart library's options for one <see cref="ChartPanel"/> (D-49): a transparent background in the theme's mode,
/// straight lines that break at unknown buckets, a y-axis that reaches zero, a solid zero line when values are signed,
/// units or currency in the axis and tooltip formatters, no animation (so nothing moves for a reader who asked for
/// reduced motion) and no toolbar.
/// </summary>
/// <remarks>
/// On Blazor Server the library's .NET label formatters are unavailable, so formatting happens twice by design: the
/// tooltip text of every point is formatted in .NET (<see cref="ChartPoint.Tooltip"/>, carried as the point's
/// <c>extra</c>), and the axis uses a small JavaScript formatter with the reader's locale and the unit
/// (<see cref="ChartFormatters"/>).
/// </remarks>
public static class AnalysisChartOptions
{
/// <summary>Builds fresh options for <paramref name="panel"/>; the chart component re-keys its chart whenever they change.</summary>
/// <param name="panel">The panel.</param>
/// <param name="palette">The theme's colours.</param>
/// <param name="culture">The reader's culture, for the axis numbers.</param>
public static ApexChartOptions<ChartPoint> Build(ChartPanel panel, ChartPalette palette, CultureInfo culture)
{
ArgumentNullException.ThrowIfNull(panel);
ArgumentNullException.ThrowIfNull(palette);
ArgumentNullException.ThrowIfNull(culture);
var mode = palette.IsDark ? Mode.Dark : Mode.Light;
var discrete = new List<MarkersDiscrete>();
for (var s = 0; s < panel.Series.Count; s++)
{
var series = panel.Series[s];
if (series.Style != ChartSeriesStyle.Line)
{
continue;
}
// A qualified point on a line is a hollow square: its shape says "not a plain value", not only its colour.
foreach (var point in series.Points.Where(p => p.IsQualified && p.Value is not null))
{
discrete.Add(new MarkersDiscrete
{
SeriesIndex = s,
DataPointIndex = point.Index,
Shape = MarkerShape.Square,
Size = 5,
FillColor = palette.Surface,
StrokeColor = series.Color,
});
}
}
var axis = new YAxis
{
ForceNiceScale = true,
Labels = new YAxisLabels { Formatter = ChartFormatters.Axis(panel.Unit, culture) },
};
// A real zero baseline: an all-positive series is measured from zero, an all-negative one up to zero.
if (!panel.HasNegative && panel.HasPositive)
{
axis.Min = 0;
}
else if (panel.HasNegative && !panel.HasPositive)
{
axis.Max = 0;
}
return new ApexChartOptions<ChartPoint>
{
Chart = new Chart
{
Background = "transparent",
ForeColor = palette.Text,
Toolbar = new Toolbar { Show = false },
Zoom = new Zoom { Enabled = false },
Animations = new Animations { Enabled = false },
RedrawOnParentResize = true,
},
Theme = new ApexCharts.Theme { Mode = mode },
DataLabels = new DataLabels { Enabled = false },
Legend = new Legend { Position = LegendPosition.Top, HorizontalAlign = Align.Left },
Grid = new Grid { BorderColor = palette.Grid, StrokeDashArray = 0 },
Stroke = new Stroke { Curve = Curve.Straight },
Markers = new Markers
{
Size = panel.Series.Select(s => s.Style == ChartSeriesStyle.Line ? 3d : 0d).ToList(),
StrokeColors = palette.Surface,
StrokeWidth = 2,
Discrete = discrete,
Hover = new MarkersHover { SizeOffset = 2 },
},
PlotOptions = new PlotOptions { Bar = new PlotOptionsBar { ColumnWidth = "70%", BorderRadius = 2 } },
States = new States
{
Active = new StatesActive
{
AllowMultipleDataPointsSelection = false,
Filter = new StatesFilter { Type = StatesFilterType.none },
},
},
Tooltip = new Tooltip
{
Enabled = true,
Shared = true,
Intersect = false,
Theme = mode,
Y = new TooltipY { Formatter = ChartFormatters.Tooltip },
},
Xaxis = new XAxis
{
Labels = new XAxisLabels { Rotate = -45, HideOverlappingLabels = true, Trim = false },
Tooltip = new AxisTooltip { Enabled = false },
},
Yaxis = [axis],
Annotations = panel.HasNegative
? new Annotations
{
Yaxis =
[
new AnnotationsYAxis { Y = 0, BorderColor = palette.Baseline, BorderWidth = 1, StrokeDashArray = 0 },
],
}
: null,
};
}
}
/// <summary>The extra data a chart point carries into the browser: its tooltip text, formatted in .NET.</summary>
public sealed record ChartPointExtra(string Text);
/// <summary>
/// JavaScript formatter functions for the chart library (strings it evaluates). Everything interpolated into them —
/// locale, unit — is written as a JSON string literal, so a unit can never break out of its string.
/// </summary>
public static class ChartFormatters
{
/// <summary>
/// The tooltip value of a point: the text formatted in .NET (<see cref="ChartPointExtra"/>), which names the unit or
/// currency and the status in words; a plain number only if that is missing.
/// </summary>
public const string Tooltip =
"function (value, opts) { "
+ "var s = opts && opts.w && opts.w.config && opts.w.config.series ? opts.w.config.series[opts.seriesIndex] : null; "
+ "var p = s && s.data ? s.data[opts.dataPointIndex] : null; "
+ "if (p && p.extra && p.extra.text) { return p.extra.text; } "
+ "return value === null || value === undefined ? '—' : String(value); }";
/// <summary>
/// An axis label formatter: the number in the reader's locale (at most two decimals) and the unit or currency
/// symbol; blank for a missing value.
/// </summary>
public static string Axis(string? unit, CultureInfo culture)
{
ArgumentNullException.ThrowIfNull(culture);
var locale = string.IsNullOrEmpty(culture.Name) ? "en" : culture.Name;
var suffix = string.IsNullOrWhiteSpace(unit) ? string.Empty : " " + unit.Trim();
return "function (value) { if (value === null || value === undefined || !isFinite(value)) { return ''; } "
+ "return new Intl.NumberFormat(" + Literal(locale) + ", { maximumFractionDigits: 2 }).format(value) + " + Literal(suffix) + "; }";
}
/// <summary>A JavaScript string literal (JSON-escaped, HTML-sensitive characters included).</summary>
public static string Literal(string text) => JsonSerializer.Serialize(text ?? string.Empty);
}
+140
View File
@@ -0,0 +1,140 @@
using System.Globalization;
using System.Text;
namespace MeterVault.App.Analysis;
/// <summary>
/// One row of the analysis CSV (D-55): one series in one bucket.
/// </summary>
/// <param name="SeriesId">The stable series identity (<c>m12</c>, <c>t3:use:kWh</c>, <c>portfolio</c>, <c>c4</c>).</param>
/// <param name="SeriesName">The series' name: a meter's, a type's or a category's (user data), or a worded measure.</param>
/// <param name="Kind">What the value measures, as its invariant identifier (<c>Consumption</c>, <c>Cost</c>).</param>
/// <param name="Unit">The value's unit (normalized, D-20), or the currency code for a cost series.</param>
/// <param name="BucketStart">The bucket's first instant, in the instance zone's local time with its offset.</param>
/// <param name="BucketEnd">The bucket's end (exclusive): the next local midnight, or now for a bucket cut at now.</param>
/// <param name="TimeZone">The instance zone id the bounds are local to.</param>
/// <param name="Value">The value; null when it is unavailable (missing, unresolved, invalid, being prepared).</param>
/// <param name="Status">The value's availability (<c>Available</c>, <c>Partial</c>, …).</param>
/// <param name="Provenance">Where the value comes from, as flag identifiers joined by <c>|</c> (<c>Measured|Estimated</c>); empty when none.</param>
/// <param name="Cost">The cost in the bucket; null when not priced or not costed.</param>
/// <param name="CostStatus">The cost's price coverage (<c>Priced</c>, <c>NotPriced</c>, …); null when the series has no cost.</param>
/// <param name="Currency">The currency of <paramref name="Cost"/>; null when the series has no cost.</param>
/// <param name="ComparisonValue">The value in the paired comparison bucket (D-06); null without a comparison or when unavailable.</param>
public sealed record AnalysisCsvRow(
string SeriesId,
string SeriesName,
string Kind,
string Unit,
DateTimeOffset BucketStart,
DateTimeOffset BucketEnd,
string TimeZone,
double? Value,
string Status,
string Provenance,
double? Cost,
string? CostStatus,
string? Currency,
double? ComparisonValue);
/// <summary>
/// Writes the analysis table as CSV (D-55): RFC 4180 quoting, a header of invariant column names, invariant numbers at
/// full precision, ISO-8601 local bucket bounds with their offset, and empty cells for unavailable values — a spreadsheet
/// or a script reads the same figures the page shows, never a fabricated zero.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class AnalysisCsvWriter
{
/// <summary>The header, in column order.</summary>
public static IReadOnlyList<string> Columns { get; } =
[
"series_id", "series_name", "kind", "unit", "bucket_start", "bucket_end", "timezone",
"value", "status", "provenance", "cost", "cost_status", "currency", "comparison_value",
];
private const string InstantFormat = "yyyy-MM-dd'T'HH:mm:sszzz";
/// <summary>Writes the header and one line per row.</summary>
public static async Task WriteAsync(TextWriter writer, IEnumerable<AnalysisCsvRow> rows, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(rows);
await writer.WriteAsync(Line(Columns).AsMemory(), cancellationToken).ConfigureAwait(false);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
await writer.WriteAsync(Line(Fields(row)).AsMemory(), cancellationToken).ConfigureAwait(false);
}
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>The whole CSV as a string (tests, small exports).</summary>
public static string Write(IEnumerable<AnalysisCsvRow> rows)
{
ArgumentNullException.ThrowIfNull(rows);
var builder = new StringBuilder();
builder.Append(Line(Columns));
foreach (var row in rows)
{
builder.Append(Line(Fields(row)));
}
return builder.ToString();
}
/// <summary>One CSV field: quoted when it holds a comma, a quote or a line break, with quotes doubled.</summary>
public static string Escape(string? field)
{
if (string.IsNullOrEmpty(field))
{
return string.Empty;
}
var needsQuotes = field.AsSpan().IndexOfAny(",\"\r\n") >= 0;
return needsQuotes ? "\"" + field.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"" : field;
}
/// <summary>A number at full precision in invariant form; empty when unknown or not finite.</summary>
public static string Number(double? value) =>
value is { } number && double.IsFinite(number) ? number.ToString("R", CultureInfo.InvariantCulture) : string.Empty;
/// <summary>An instant as local ISO-8601 with its offset (<c>2026-09-01T00:00:00+02:00</c>).</summary>
public static string Instant(DateTimeOffset value) => value.ToString(InstantFormat, CultureInfo.InvariantCulture);
/// <summary>User text made safe to open in a spreadsheet: a leading formula character becomes literal text.</summary>
public static string Text(string? value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value[0] is '=' or '+' or '-' or '@' or '\t' or '\r' or '\n' ? "'" + value : value;
}
private static IEnumerable<string> Fields(AnalysisCsvRow row) =>
[
row.SeriesId,
Text(row.SeriesName),
row.Kind,
Text(row.Unit),
Instant(row.BucketStart),
Instant(row.BucketEnd),
row.TimeZone,
Number(row.Value),
row.Status,
row.Provenance,
Number(row.Cost),
row.CostStatus ?? string.Empty,
row.Currency ?? string.Empty,
Number(row.ComparisonValue),
];
private static string Line(IEnumerable<string> fields) => string.Join(',', fields.Select(Escape)) + "\r\n";
}
+113
View File
@@ -0,0 +1,113 @@
using MeterVault.Core.Analysis;
namespace MeterVault.App.Analysis;
/// <summary>
/// What a page shows when its URL does not say (D-02, D-46): the period preset, bucket size, comparison, metric and
/// scope. A default applies only to a key that is absent; <see cref="AnalysisQuery"/> never writes a key whose value
/// equals the target page's default, so links stay short and a page never rewrites its address on load.
/// </summary>
/// <remarks>
/// <para>
/// The Overview defaults to month to date and every history page — the meter Analysis tab, an energy type's History,
/// the Analysis page (<c>/trends</c>), Solar and Consumables — to the last 12 months (D-02). Both compare with the
/// previous year by default (amendment A-13): "this month so far against the same days last year" is the question the
/// Overview answers, and a seasonal utility compared with the months just before would read as a trend that is only
/// the season.
/// </para>
/// <para>
/// A null <see cref="Metric"/> means "the scope's natural metric": a meter's own quantity kind, a type's use, the
/// portfolio's cost. The page decides it; the URL only carries a metric somebody chose.
/// </para>
/// <para>
/// <see cref="Scope"/> is the scope a page is about when its URL names none: the portfolio on the Overview and the
/// Analysis page, the meter on a meter page (<see cref="ForScope"/>), the type on an energy type page. A route that
/// implies its scope therefore never writes it, and a link that carries the period onward never carries the scope.
/// </para>
/// </remarks>
public sealed record AnalysisDefaults
{
/// <exception cref="ArgumentException">
/// <paramref name="period"/> is <see cref="PeriodPreset.Custom"/>, which has no dates to default to, or
/// <paramref name="comparison"/> is a year comparison without its year.
/// </exception>
public AnalysisDefaults(
PeriodPreset period,
BucketSize bucket,
ComparisonRequest comparison,
AnalysisMetric? metric = null,
QueryScope? scope = null)
{
ArgumentNullException.ThrowIfNull(comparison);
if (period == PeriodPreset.Custom || !Enum.IsDefined(period))
{
throw new ArgumentException("A page default is a preset, never a custom range.", nameof(period));
}
if (comparison.Kind == ComparisonKind.Year && comparison.Year is null)
{
throw new ArgumentException("A year comparison needs its year.", nameof(comparison));
}
Period = period;
Bucket = bucket;
Comparison = comparison;
Metric = metric;
Scope = scope ?? QueryScope.Portfolio;
}
/// <summary>The Overview (<c>/</c>): month to date, automatic buckets, compared with the previous year.</summary>
public static AnalysisDefaults Overview { get; } =
new(PeriodPreset.MonthToDate, BucketSize.Auto, new ComparisonRequest(ComparisonKind.PreviousYear));
/// <summary>
/// Every history view — the meter Analysis tab, an energy type's History, <c>/trends</c>, Solar, Consumables: the
/// last 12 months (12 calendar buckets ending with the current partial month), automatic buckets, compared with the
/// previous year.
/// </summary>
public static AnalysisDefaults History { get; } =
new(PeriodPreset.Last12Months, BucketSize.Auto, new ComparisonRequest(ComparisonKind.PreviousYear));
/// <summary>The CSV export (<c>/export/analysis.csv</c>): the history defaults, so a link written for it is explicit about everything else.</summary>
public static AnalysisDefaults Export => History;
public PeriodPreset Period { get; }
public BucketSize Bucket { get; }
public ComparisonRequest Comparison { get; }
/// <summary>The metric; null for the scope's natural one.</summary>
public AnalysisMetric? Metric { get; }
/// <summary>The scope the page is about when its URL names none.</summary>
public QueryScope Scope { get; }
/// <summary>These defaults on a page whose route implies <paramref name="scope"/> (a meter page, an energy type page).</summary>
public AnalysisDefaults ForScope(QueryScope scope) => new(Period, Bucket, Comparison, Metric, scope);
/// <summary>These defaults with another default metric.</summary>
public AnalysisDefaults WithMetric(AnalysisMetric? metric) => new(Period, Bucket, Comparison, metric, Scope);
/// <summary>
/// The defaults of the page at <paramref name="path"/> (base-relative or absolute path, query ignored): the Overview's
/// for <c>/</c>, the history defaults for everything else — for components outside a page (the meter search) that
/// carry the current page's period onward.
/// </summary>
public static AnalysisDefaults ForPath(string? path)
{
var text = path ?? string.Empty;
var cut = text.IndexOfAny(['?', '#']);
if (cut >= 0)
{
text = text[..cut];
}
if (Uri.TryCreate(text, UriKind.Absolute, out var absolute) && absolute.Scheme is "http" or "https")
{
text = absolute.AbsolutePath;
}
return text.Trim('/').Length == 0 ? Overview : History;
}
}
+270
View File
@@ -0,0 +1,270 @@
using System.Globalization;
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.App.Analysis;
/// <summary>The rows of an analysis export, or why the request cannot be answered (a 400 message).</summary>
/// <param name="Error">A short invariant message for an invalid request; null when there are rows.</param>
/// <param name="FileName">The download's file name.</param>
/// <param name="Rows">One row per bucket and series.</param>
public sealed record AnalysisExportResult(string? Error, string FileName, IReadOnlyList<AnalysisCsvRow> Rows)
{
public static AnalysisExportResult Invalid(string message) => new(message, string.Empty, []);
}
/// <summary>
/// Builds the analysis table the CSV export streams (D-55) from the same URL keys, the same
/// <see cref="AnalysisQuery"/> resolution and the same readers as the pages — so the file holds exactly the figures on
/// screen: quantities from <see cref="AnalysisReader"/>, costs from <see cref="CostReader"/>, comparisons paired by
/// bucket.
/// </summary>
/// <remarks>
/// <para>
/// <b>Series.</b> A meter or a meter selection exports each meter's own series (whatever the metric: a meter measures
/// what it measures), with its cost by the meter's rule. An energy type or the portfolio exports the per-type measures
/// of the metric (consumption: total use and grid import, never added), or every measure without one. The cost metric —
/// and a category, which is analysed by cost — exports one cost series per scope (per meter for a selection).
/// </para>
/// <para>
/// <b>Refused.</b> Any URL key that the query could not read (a notice), a category asked for a quantity, the tank
/// balance (not a bucketed series), too many meters or too many buckets, and an unknown meter, type or category — each
/// is a 400 with a short message, never a 500 and never a silently different export.
/// </para>
/// </remarks>
public sealed class AnalysisExport(
AnalysisReader reader,
CostReader costs,
AnalysisPeriods periods,
IDbContextFactory<MeterVaultDbContext> contextFactory,
TimeProvider time)
{
/// <summary>Reads what <paramref name="query"/> shows, as CSV rows.</summary>
public async Task<AnalysisExportResult> PrepareAsync(AnalysisQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (query.Notices.Count > 0)
{
return AnalysisExportResult.Invalid(string.Join(" ", query.Notices.Select(n => n.Describe())));
}
if (query.Metric == AnalysisMetric.Balance)
{
return AnalysisExportResult.Invalid("The tank balance is not a bucketed series and cannot be exported; use metric=consumption.");
}
var byCost = query.Metric == AnalysisMetric.Cost || query.Scope.Kind == QueryScopeKind.Category;
if (query.Scope.Kind == QueryScopeKind.Category && query.Metric is { } metric && metric.IsQuantity())
{
return AnalysisExportResult.Invalid("A cost category is analysed by cost; use metric=cost.");
}
var period = await periods.ResolveAsync(query, time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
var names = await Names.LoadAsync(contextFactory, cancellationToken).ConfigureAwait(false);
var rows = byCost
? await CostRowsAsync(query, period, names, cancellationToken).ConfigureAwait(false)
: await QuantityRowsAsync(query, period, names, cancellationToken).ConfigureAwait(false);
return rows.Error is not null ? rows : rows with { FileName = FileName(query, period) };
}
private async Task<AnalysisExportResult> QuantityRowsAsync(
AnalysisQuery query, ResolvedPeriod period, Names names, CancellationToken cancellationToken)
{
if (query.Scope.Kind == QueryScopeKind.EnergyType && !names.Types.ContainsKey(query.Scope.Id!.Value))
{
return AnalysisExportResult.Invalid(string.Create(CultureInfo.InvariantCulture, $"Unknown energy type: {query.Scope.Id}."));
}
if (query.Scope.MeterIds.FirstOrDefault(id => !names.Meters.ContainsKey(id)) is var missing and > 0)
{
return AnalysisExportResult.Invalid(string.Create(CultureInfo.InvariantCulture, $"Unknown meter: {missing}."));
}
var request = query.ToAnalysisRequest(period)!;
var result = await reader.ReadAsync(request, cancellationToken).ConfigureAwait(false);
if (Refused(result.Refusal, result.Plan) is { } refusal)
{
return AnalysisExportResult.Invalid(refusal);
}
var buckets = result.Plan.Buckets;
var zone = reader.Zone;
var rows = new List<AnalysisCsvRow>();
if (query.Scope.Kind is QueryScopeKind.Meter or QueryScopeKind.Meters)
{
foreach (var series in result.Series)
{
var cost = await MeterCostAsync(series.MeterId!.Value, period, result.Plan, cancellationToken).ConfigureAwait(false);
rows.AddRange(Rows(series, series.Name, buckets, zone, cost));
}
return new AnalysisExportResult(null, string.Empty, rows);
}
var measures = query.Metric is { } metric ? AnalysisMetrics.MeasuresOf(metric) : null;
foreach (var series in result.Measures.Where(m => measures is null || (m.Key.Measure is { } measure && measures.Contains(measure))))
{
var typeName = series.EnergyTypeId is { } typeId ? names.Types.GetValueOrDefault(typeId, string.Empty) : string.Empty;
var name = series.Key.Measure is { } measure ? typeName + " · " + measure.Display() : typeName;
rows.AddRange(Rows(series, name, buckets, zone, cost: null));
}
return new AnalysisExportResult(null, string.Empty, rows);
}
private async Task<AnalysisExportResult> CostRowsAsync(
AnalysisQuery query, ResolvedPeriod period, Names names, CancellationToken cancellationToken)
{
var rows = new List<AnalysisCsvRow>();
BucketPlan? plan = null;
foreach (var request in query.ToCostRequests(period))
{
// Every scope of a selection is priced in the first one's buckets, so the rows line up.
var current = await costs.ReadAsync(plan is null ? request : request with { Plan = plan }, cancellationToken).ConfigureAwait(false);
if (current.Refusal == CostRefusal.UnknownScope)
{
return AnalysisExportResult.Invalid("Unknown " + request.Scope.ToString().Replace(':', ' ') + ".");
}
if (Refused(current.Refusal == CostRefusal.TooManyPoints ? AnalysisRefusal.TooManyPoints : AnalysisRefusal.None, current.Plan) is { } refusal)
{
return AnalysisExportResult.Invalid(refusal);
}
plan ??= current.Plan;
IReadOnlyList<CostAmount>? previous = null;
if (query.Comparison.Kind != ComparisonKind.None)
{
var comparison = query.ToCostComparison(request with { Plan = current.Plan }, current.Plan);
if (comparison.Request is { } comparisonRequest)
{
previous = (await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false)).Buckets;
}
}
var (id, name) = CostSeries(request.Scope, names);
var buckets = current.Plan.Buckets;
for (var i = 0; i < buckets.Count && i < current.Buckets.Count; i++)
{
var amount = current.Buckets[i];
rows.Add(new AnalysisCsvRow(
id,
name,
nameof(QuantityKind.Cost),
current.Currency,
Local(buckets[i].From, reader.Zone),
Local(buckets[i].To, reader.Zone),
reader.Zone.Id,
amount.Cost,
// Nothing booked is unknown, never "Available" beside an empty value (§4.3, FigureText.IsNothingBooked).
(FigureText.IsNothingBooked(amount) ? BucketStatus.Missing : amount.Availability).ToString(),
string.Empty,
amount.Cost,
amount.Status.ToString(),
current.Currency,
previous is not null && i < previous.Count ? previous[i].Cost : null));
}
}
return new AnalysisExportResult(null, string.Empty, rows);
}
/// <summary>A meter's cost in the quantity buckets; null when the meter is not costed (generation, runtime, no rule).</summary>
private async Task<CostAnalysis?> MeterCostAsync(int meterId, ResolvedPeriod period, BucketPlan plan, CancellationToken cancellationToken)
{
var analysis = await costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meterId), period) { Plan = plan }, cancellationToken)
.ConfigureAwait(false);
return analysis.Refusal != CostRefusal.None || analysis.Meter is { Rule: MeterCostRule.None } ? null : analysis;
}
private static IEnumerable<AnalysisCsvRow> Rows(
AnalysisSeries series, string name, IReadOnlyList<AnalysisBucket> buckets, TimeZoneInfo zone, CostAnalysis? cost)
{
for (var i = 0; i < buckets.Count && i < series.Values.Count; i++)
{
var value = series.Values[i];
var amount = cost is not null && i < cost.Buckets.Count ? cost.Buckets[i] : null;
var previous = series.Comparison is { } comparison && i < comparison.Values.Count ? comparison.Values[i].Value : null;
yield return new AnalysisCsvRow(
series.Key.Id,
name,
series.Kind.ToString(),
series.Unit,
Local(buckets[i].From, zone),
Local(buckets[i].To, zone),
zone.Id,
value.Value,
value.Status.ToString(),
ProvenanceTokens(value.Provenance),
amount?.Cost,
amount?.Status.ToString(),
amount is null ? null : cost!.Currency,
previous);
}
}
/// <summary>The set flags as identifiers joined by <c>|</c> (<c>Measured|Estimated</c>); empty for none.</summary>
private static string ProvenanceTokens(Provenance provenance) =>
provenance == Provenance.None
? string.Empty
: string.Join('|', Enum.GetValues<Provenance>().Where(f => f != Provenance.None && provenance.HasFlag(f)));
private static (string Id, string Name) CostSeries(CostScope scope, Names names) => scope.Kind switch
{
CostScopeKind.EnergyType => (Token('t', scope.Id), names.Types.GetValueOrDefault(scope.Id!.Value, string.Empty)),
CostScopeKind.Meter => (Token('m', scope.Id), names.Meters.GetValueOrDefault(scope.Id!.Value, string.Empty)),
CostScopeKind.Category => (Token('c', scope.Id), names.Categories.GetValueOrDefault(scope.Id!.Value, string.Empty)),
_ => (QueryScope.Portfolio.Token, QueryScopeKind.Portfolio.Display()),
};
private static string Token(char prefix, int? id) => prefix + id!.Value.ToString(CultureInfo.InvariantCulture);
private static DateTimeOffset Local(DateTimeOffset instant, TimeZoneInfo zone) => TimeZoneInfo.ConvertTime(instant, zone);
/// <summary>The 400 message of a refused request; null when it was not refused.</summary>
private static string? Refused(AnalysisRefusal refusal, BucketPlan plan) => refusal switch
{
AnalysisRefusal.TooManySeries => string.Create(
CultureInfo.InvariantCulture, $"At most {AnalysisLimits.MaxSeries} meters can be exported side by side."),
AnalysisRefusal.TooManyPoints => string.Create(
CultureInfo.InvariantCulture,
$"bucket={AnalysisTokens.Format(plan.Size)} gives {plan.PointCount} buckets, more than {AnalysisLimits.MaxPoints}")
+ (plan.Suggested is { } suggested ? "; use bucket=" + AnalysisTokens.Format(suggested) + "." : "."),
_ => null,
};
/// <summary><c>metervault-meter-42-consumption-2025-10-01-2026-09-19.csv</c>.</summary>
private static string FileName(AnalysisQuery query, ResolvedPeriod period)
{
var scope = query.Scope.ToString().Replace(':', '-').Replace(',', '-');
var metric = query.Metric is { } m ? AnalysisMetrics.Format(m) : query.Scope.Kind == QueryScopeKind.Category ? "cost" : "quantity";
var last = period.HasNotStarted() ? period.LastDay : period.EffectiveLastDay();
var first = period.FirstDay <= last ? period.FirstDay : last;
return $"metervault-{scope}-{metric}-{AnalysisTokens.FormatDate(first)}-{AnalysisTokens.FormatDate(last)}.csv";
}
/// <summary>The names the rows carry: meters, energy types and categories (user data, as stored).</summary>
private sealed record Names(
IReadOnlyDictionary<int, string> Meters,
IReadOnlyDictionary<int, string> Types,
IReadOnlyDictionary<int, string> Categories)
{
public static async Task<Names> LoadAsync(IDbContextFactory<MeterVaultDbContext> factory, CancellationToken cancellationToken)
{
await using var db = await factory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var meters = await db.Meters.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name, cancellationToken).ConfigureAwait(false);
var types = await db.EnergyTypes.AsNoTracking().ToDictionaryAsync(t => (int)t.Id, t => t.DisplayName, cancellationToken).ConfigureAwait(false);
var categories = await db.CostCategories.AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken).ConfigureAwait(false);
return new Names(meters, types, categories);
}
}
}
@@ -0,0 +1,57 @@
using System.Text;
namespace MeterVault.App.Analysis;
/// <summary>
/// <c>GET /export/analysis.csv</c> (D-55): the analysis table of the URL keys the pages use — <c>scope</c>/<c>id</c>/
/// <c>ids</c>, <c>metric</c>, <c>period</c>, <c>from</c>, <c>to</c>, <c>bucket</c>, <c>compare</c> — as a CSV download.
/// Build links to it with <see cref="AnalysisLinks.Export"/>.
/// </summary>
/// <remarks>
/// A UI endpoint, not part of the versioned REST API: it serves the same reader a signed-in browser already sees and
/// needs no API key, like the pages themselves. Invalid input is a 400 with a short plain-text message; nothing a user
/// can type into the URL makes it a 500.
/// </remarks>
public static class AnalysisExportEndpoints
{
public static IEndpointRouteBuilder MapAnalysisExport(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapGet(AnalysisLinks.ExportPath, async (HttpContext http, AnalysisExport export, ILogger<AnalysisExport> logger, CancellationToken ct) =>
{
var query = AnalysisQuery.Parse(http.Request.Query, AnalysisDefaults.Export);
AnalysisExportResult prepared;
try
{
prepared = await export.PrepareAsync(query, ct);
}
catch (ArgumentException ex)
{
// The readers reject a combination they cannot answer with an ArgumentException; that is the request's
// fault, not the server's.
logger.LogWarning(ex, "Analysis export refused {Query}", query);
return Results.Text("This combination of keys cannot be exported.", "text/plain; charset=utf-8", Encoding.UTF8, StatusCodes.Status400BadRequest);
}
if (prepared.Error is { } error)
{
return Results.Text(error, "text/plain; charset=utf-8", Encoding.UTF8, StatusCodes.Status400BadRequest);
}
return Results.Stream(
async stream =>
{
// A byte-order mark, so spreadsheet programs read the umlauts of meter names as UTF-8.
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
await AnalysisCsvWriter.WriteAsync(writer, prepared.Rows, ct);
},
"text/csv; charset=utf-8",
prepared.FileName);
})
.ExcludeFromDescription();
return endpoints;
}
}
+117
View File
@@ -0,0 +1,117 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
namespace MeterVault.App.Analysis;
/// <summary>
/// What an analysis view charts (D-47 <c>metric=consumption|generation|export|runtime|net|cost|balance</c>): a
/// quantity kind, the cost, or a tank's balance. The URL token is the stable identifier; the visible label comes from
/// <see cref="Localization.DisplayNames"/>.
/// </summary>
public enum AnalysisMetric
{
Consumption,
Generation,
Export,
Runtime,
/// <summary>A signed virtual result (a difference of kinds, D-26).</summary>
Net,
/// <summary>The cost of the scope (D-34 D-43).</summary>
Cost,
/// <summary>A tank's level over time (consumables).</summary>
Balance,
}
/// <summary>URL tokens and meaning of <see cref="AnalysisMetric"/>.</summary>
public static class AnalysisMetrics
{
private static readonly (AnalysisMetric Value, string Token)[] Tokens =
[
(AnalysisMetric.Consumption, "consumption"),
(AnalysisMetric.Generation, "generation"),
(AnalysisMetric.Export, "export"),
(AnalysisMetric.Runtime, "runtime"),
(AnalysisMetric.Net, "net"),
(AnalysisMetric.Cost, "cost"),
(AnalysisMetric.Balance, "balance"),
];
/// <summary>The URL token of a metric (<c>consumption</c>, <c>cost</c>, …).</summary>
public static string Format(AnalysisMetric metric)
{
foreach (var (value, token) in Tokens)
{
if (value == metric)
{
return token;
}
}
throw new ArgumentOutOfRangeException(nameof(metric), metric, "No URL token for this metric.");
}
/// <summary>Parses a metric token, ignoring case and surrounding blanks; false for anything else.</summary>
public static bool TryParse(string? token, out AnalysisMetric metric)
{
metric = default;
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var text = token.Trim();
foreach (var (value, name) in Tokens)
{
if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase))
{
metric = value;
return true;
}
}
return false;
}
/// <summary>True for the quantity metrics (everything but cost and balance), which the analysis reader answers.</summary>
public static bool IsQuantity(this AnalysisMetric metric) => metric is not (AnalysisMetric.Cost or AnalysisMetric.Balance);
/// <summary>The quantity kind a quantity metric charts; null for cost and balance.</summary>
public static QuantityKind? QuantityKindOf(AnalysisMetric metric) => metric switch
{
AnalysisMetric.Consumption => QuantityKind.Consumption,
AnalysisMetric.Generation => QuantityKind.Generation,
AnalysisMetric.Export => QuantityKind.Export,
AnalysisMetric.Runtime => QuantityKind.Runtime,
AnalysisMetric.Net => QuantityKind.Net,
_ => null,
};
/// <summary>
/// The per-type measures (D-22) a quantity metric shows for an energy type or the portfolio: consumption is the
/// household use and, separately, the billed grid import (never added to each other); net has no measure, being a
/// virtual meter's own result.
/// </summary>
public static IReadOnlyList<TotalsMeasure> MeasuresOf(AnalysisMetric metric) => metric switch
{
AnalysisMetric.Consumption => [TotalsMeasure.Use, TotalsMeasure.GridImport],
AnalysisMetric.Generation => [TotalsMeasure.Generation],
AnalysisMetric.Export => [TotalsMeasure.Export],
AnalysisMetric.Runtime => [TotalsMeasure.Runtime],
_ => [],
};
/// <summary>The metric a quantity kind is charted under; <see cref="QuantityKind.Indicator"/> has none.</summary>
public static AnalysisMetric? MetricOf(QuantityKind kind) => kind switch
{
QuantityKind.Consumption => AnalysisMetric.Consumption,
QuantityKind.Generation => AnalysisMetric.Generation,
QuantityKind.Export => AnalysisMetric.Export,
QuantityKind.Runtime => AnalysisMetric.Runtime,
QuantityKind.Net => AnalysisMetric.Net,
QuantityKind.Cost => AnalysisMetric.Cost,
_ => null,
};
}
+189
View File
@@ -0,0 +1,189 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using Microsoft.AspNetCore.Components;
namespace MeterVault.App.Analysis;
/// <summary>One breadcrumb: its text (a name is user data) and its link; null for the current page.</summary>
public sealed record Crumb(string Text, string? Href);
/// <summary>
/// Where the analysis components lead (D-46, D-48, D-51, brief §4.3): replacing the page's analysis state from the
/// toolbar, drilling into a bucket, going to the latest data, and the breadcrumb trail — each keeping the period.
/// </summary>
public static class AnalysisNavigation
{
/// <summary>
/// Writes <paramref name="query"/> into the current page's address, replacing the history entry (D-46: toolbar and
/// tab changes replace, drill-downs push). Keys equal to the page defaults are removed; other keys (<c>tab</c>) stay.
/// </summary>
public static void Replace(NavigationManager navigation, AnalysisQuery query, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(navigation);
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(defaults);
navigation.NavigateTo(navigation.GetUriWithQueryParameters(query.ToNavigationParameters(defaults)), replace: true);
}
/// <summary>
/// The address of the current page showing <paramref name="query"/> (for a drill-down, which pushes a new history
/// entry: <c>Nav.NavigateTo(AnalysisNavigation.UriFor(Nav, next, Defaults))</c>).
/// </summary>
public static string UriFor(NavigationManager navigation, AnalysisQuery query, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(navigation);
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(defaults);
return navigation.GetUriWithQueryParameters(query.ToNavigationParameters(defaults));
}
/// <summary>The local days a bucket stands for, both inclusive: a bucket cut at now stands for its whole unit (its <see cref="AnalysisBucket.NominalEndDay"/>).</summary>
public static (DateOnly First, DateOnly Last) DaysOf(AnalysisBucket bucket)
{
ArgumentNullException.ThrowIfNull(bucket);
var end = bucket.NominalEndDay ?? bucket.EndDay;
return (bucket.FirstDay, end > bucket.FirstDay ? end.AddDays(-1) : bucket.FirstDay);
}
/// <summary>
/// The finer bucket sizes a drill into <paramref name="size"/> may use, most useful first: a year opens its months,
/// a month its days (or weeks, when the data resolves weeks but not days), a week its days; a day has none.
/// </summary>
public static IReadOnlyList<BucketSize> FinerSizes(BucketSize size) => size switch
{
BucketSize.Year => [BucketSize.Month],
BucketSize.Month => [BucketSize.Day, BucketSize.Week],
BucketSize.Week => [BucketSize.Day],
_ => [],
};
/// <summary>
/// The drill-down of a chart bucket (D-51): the same scope, metric and comparison over the bucket's days, in the next
/// finer bucket the data supports. Null when there is none — a day, or data too coarse for anything finer (a monthly
/// import) — and the page opens the bucket's records instead (<see cref="NormalizedData"/>).
/// </summary>
/// <param name="query">The page's analysis state.</param>
/// <param name="bucket">The clicked bucket.</param>
/// <param name="coarsestResolution">
/// The coarsest resolution among the charted series (<see cref="Infrastructure.Analysis.AnalysisSeries.Resolution"/>);
/// null when unknown, which allows any finer size.
/// </param>
/// <remarks>
/// A named-year comparison (<c>year:2024</c>) needs a calendar year; drilling below a year turns it into the same
/// period a year earlier, which is what a year comparison of a month means.
/// </remarks>
public static AnalysisQuery? DrillInto(AnalysisQuery query, AnalysisBucket bucket, ResolutionClass? coarsestResolution = null)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(bucket);
var floor = coarsestResolution is { } resolution ? BucketPlanner.MinimumSizeFor(resolution) : BucketSize.Day;
BucketSize? finer = null;
foreach (var size in FinerSizes(bucket.Size))
{
if (size >= floor)
{
finer = size;
break;
}
}
var (first, last) = DaysOf(bucket);
if (finer is not { } next || !PeriodResolver.IsValidCustomRange(first, last))
{
return null;
}
var drilled = query.WithCustomRange(first, last).WithBucket(next);
var wholeYear = first is { Month: 1, Day: 1 } && last.Month == 12 && last.Day == 31 && first.Year == last.Year;
return query.Comparison.Kind == ComparisonKind.Year && !wholeYear
? drilled.WithComparison(new ComparisonRequest(ComparisonKind.PreviousYear))
: drilled;
}
/// <summary>The meter's Normalized data tab filtered to a bucket's days (D-50, D-51), keeping the rest of the analysis state.</summary>
public static string NormalizedData(int meterId, AnalysisQuery query, AnalysisBucket bucket)
{
ArgumentNullException.ThrowIfNull(query);
var (first, last) = DaysOf(bucket);
var target = PeriodResolver.IsValidCustomRange(first, last) ? query.WithCustomRange(first, last) : query;
return MeterLinks.Detail(meterId, MeterLinks.TabNormalized, null, target);
}
/// <summary>
/// "Go to latest data" (brief §4.3): a period of the same kind ending with the latest data — its month for a month
/// preset, its calendar year for a year preset, the 12 or 24 months up to it for those, a custom range of the same
/// length ending on the last available day. Null without availability.
/// </summary>
public static AnalysisQuery? LatestData(AnalysisQuery query, AvailableRange? availability)
{
ArgumentNullException.ThrowIfNull(query);
if (availability is null)
{
return null;
}
var lastDay = availability.LastDay;
var month = new DateOnly(lastDay.Year, lastDay.Month, 1);
var monthEnd = month.AddMonths(1).AddDays(-1);
var (first, last) = query.Period switch
{
PeriodPreset.MonthToDate or PeriodPreset.LastMonth => (month, monthEnd),
PeriodPreset.YearToDate or PeriodPreset.PreviousYear => (new DateOnly(lastDay.Year, 1, 1), new DateOnly(lastDay.Year, 12, 31)),
PeriodPreset.Last24Months => (month.AddMonths(-23), monthEnd),
PeriodPreset.Custom when query.From is { } from && query.To is { } to && to >= from =>
(lastDay.AddDays(from.DayNumber - to.DayNumber), lastDay),
_ => (month.AddMonths(-11), monthEnd),
};
if (first < PeriodResolver.MinSupportedDate)
{
first = PeriodResolver.MinSupportedDate;
}
return PeriodResolver.IsValidCustomRange(first, last) ? query.WithCustomRange(first, last) : null;
}
/// <summary>
/// The breadcrumb trail (D-48, brief §3.1): Overview → energy type → meter, each link carrying the period, bucket,
/// comparison and metric of <paramref name="query"/>; the last crumb is the current page and has no link.
/// </summary>
/// <param name="query">The current page's analysis state; null carries nothing.</param>
/// <param name="energyType">The energy type (id, name), when the page is inside one.</param>
/// <param name="meter">The meter (id, name), when the page is a meter's.</param>
/// <param name="current">A label for the current page below them (a tab, a specialized view); null when the last of the above is the page.</param>
public static IReadOnlyList<Crumb> Breadcrumbs(
AnalysisQuery? query,
(int Id, string Name)? energyType = null,
(int Id, string Name)? meter = null,
string? current = null)
{
var trail = new List<Crumb> { new(Strings.Nav_Overview, AnalysisLinks.Overview(query)) };
if (energyType is { } type)
{
trail.Add(new Crumb(type.Name, AnalysisLinks.EnergyType(type.Id, null, query)));
}
if (meter is { } m)
{
trail.Add(new Crumb(m.Name, MeterLinks.Analysis(m.Id, query)));
}
if (!string.IsNullOrWhiteSpace(current))
{
trail.Add(new Crumb(current, null));
}
else
{
trail[^1] = trail[^1] with { Href = null };
}
return trail;
}
}
+53
View File
@@ -0,0 +1,53 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
namespace MeterVault.App.Analysis;
/// <summary>
/// Resolves an <see cref="AnalysisQuery"/> the way every page and the CSV export do (D-01, D-02, D-19): against the
/// captured now, in the zone the readers cut days in, and — for <c>all</c> only — over the scope's availability, which
/// is the one thing resolving has to read.
/// </summary>
/// <remarks>
/// Availability follows what the query shows: the cost scope's (billed meters plus manual costs) for the cost metric and
/// for a category, the quantity scope's otherwise (D-19). Nothing is read for any other preset.
/// </remarks>
public sealed class AnalysisPeriods(AnalysisReader reader, CostReader costs)
{
/// <summary>The zone periods are resolved in: the readers' (<c>MeterVault__TimeZone</c>).</summary>
public TimeZoneInfo Zone => reader.Zone;
/// <summary>Resolves <paramref name="query"/> as of <paramref name="now"/>.</summary>
public async Task<ResolvedPeriod> ResolveAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var availability = query.Period == PeriodPreset.AllHistory
? await AvailabilityAsync(query, now, cancellationToken).ConfigureAwait(false)
: null;
return query.Resolve(now, reader.Zone, availability);
}
/// <summary>What the query's scope has data for as of <paramref name="now"/>, capped at now (D-19); null without any.</summary>
public async Task<AvailableRange?> AvailabilityAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (query.Metric == AnalysisMetric.Cost || query.Scope.ToAnalysisScope() is not { } scope)
{
var ranges = new List<AvailableRange?>();
foreach (var costScope in query.Scope.ToCostScopes())
{
var availability = await costs.GetAvailabilityAsync(costScope, now, cancellationToken).ConfigureAwait(false);
ranges.Add(availability.Range);
}
return AvailableRange.Union(ranges, costs.Zone);
}
var quantity = await reader.GetAvailabilityAsync(scope, now, cancellationToken).ConfigureAwait(false);
return quantity.Quantity;
}
}
+725
View File
@@ -0,0 +1,725 @@
using System.Globalization;
using System.Text;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
namespace MeterVault.App.Analysis;
/// <summary>The URL keys of the analysis state (D-02, D-46, D-47). Stable invariant identifiers, never localized.</summary>
public static class AnalysisUrlKeys
{
public const string Scope = "scope";
public const string Id = "id";
public const string Ids = "ids";
public const string Metric = "metric";
public const string Period = "period";
public const string From = "from";
public const string To = "to";
public const string Bucket = "bucket";
public const string Compare = "compare";
/// <summary>Every analysis key, in the order links write them.</summary>
public static IReadOnlyList<string> All { get; } = [Scope, Id, Ids, Metric, Period, From, To, Bucket, Compare];
}
/// <summary>Which parts of an <see cref="AnalysisQuery"/> a URL is written with.</summary>
[Flags]
public enum AnalysisQueryParts
{
None = 0,
/// <summary><c>scope</c>, <c>id</c>, <c>ids</c>.</summary>
Scope = 1,
/// <summary><c>metric</c>.</summary>
Metric = 2,
/// <summary><c>period</c>, <c>from</c>, <c>to</c>.</summary>
Period = 4,
/// <summary><c>bucket</c>.</summary>
Bucket = 8,
/// <summary><c>compare</c>.</summary>
Comparison = 16,
/// <summary>What a link carries onward to another page (D-47): everything but the scope, which the target's route names.</summary>
Carry = Metric | Period | Bucket | Comparison,
All = Scope | Carry,
}
/// <summary>
/// The analysis state of a page, parsed from its URL (D-02, D-46, D-47, brief §4.1): period preset (or custom dates),
/// bucket size, comparison, metric and scope — immutable and compared by value, so a page reloads its analysis only
/// when this value changes.
/// </summary>
/// <remarks>
/// <para>
/// <b>Reading.</b> <see cref="Parse(string, AnalysisDefaults)"/> reads the keys of <see cref="AnalysisUrlKeys"/>. A key
/// that is absent takes the page default (<see cref="AnalysisDefaults"/>); a key with an invalid value takes the default
/// too and adds a <see cref="Notices">notice</see> — a hand-edited or stale link never breaks the page (D-02). Tokens
/// are read case-insensitively; <c>previous-year</c> / <c>previous-period</c> are accepted for <c>prev-year</c> /
/// <c>prev-period</c>. <c>from</c>/<c>to</c> (yyyy-MM-dd, inclusive) make a custom range when <c>period</c> is
/// <c>custom</c> or absent, and are ignored beside another preset. Explicit meter selections keep at most
/// <see cref="AnalysisLimits.MaxSeries"/> meters.
/// </para>
/// <para>
/// <b>Writing.</b> <see cref="ToQueryParameters"/>, <see cref="ToNavigationParameters"/> and <see cref="AppendTo"/>
/// write the canonical tokens and omit every key equal to the <em>target</em> page's default; a custom range is written
/// as <c>from</c> and <c>to</c> alone. Links to another page carry <see cref="AnalysisQueryParts.Carry"/> (period,
/// bucket, comparison, metric), because the target's route names its scope.
/// </para>
/// <para>
/// <b>Resolving.</b> <see cref="Resolve"/> turns the preset into a <see cref="ResolvedPeriod"/> through
/// <see cref="PeriodResolver"/>, once per load, against a captured now and the instance zone; <c>all</c> spans the
/// scope's availability (D-19). <see cref="ToAnalysisRequest"/>, <see cref="ToCostRequests"/> and
/// <see cref="ToCostComparison"/> build the reader requests in one place, so pages and the CSV export ask identically.
/// </para>
/// <para>
/// <see cref="Notices"/> are not part of the value: two URLs that resolve to the same state are equal, whatever was
/// wrong with them. The <c>With…</c> helpers return a query without notices — a choice made in the toolbar is clean.
/// </para>
/// </remarks>
public sealed class AnalysisQuery : IEquatable<AnalysisQuery>
{
private AnalysisQuery(
PeriodPreset period,
DateOnly? from,
DateOnly? to,
BucketSize bucket,
ComparisonRequest comparison,
AnalysisMetric? metric,
QueryScope scope,
IReadOnlyList<AnalysisQueryNotice> notices)
{
Period = period;
From = from;
To = to;
Bucket = bucket;
Comparison = comparison;
Metric = metric;
Scope = scope;
Notices = notices;
}
/// <summary>The period preset; <see cref="PeriodPreset.Custom"/> with <see cref="From"/>/<see cref="To"/>.</summary>
public PeriodPreset Period { get; }
/// <summary>The first local day of a custom range (inclusive); null for a preset.</summary>
public DateOnly? From { get; }
/// <summary>The last local day of a custom range (inclusive); null for a preset.</summary>
public DateOnly? To { get; }
public BucketSize Bucket { get; }
public ComparisonRequest Comparison { get; }
/// <summary>The chosen metric; null for the scope's natural one (the page decides).</summary>
public AnalysisMetric? Metric { get; }
public QueryScope Scope { get; }
/// <summary>What in the URL was not used as written; not part of equality.</summary>
public IReadOnlyList<AnalysisQueryNotice> Notices { get; }
public bool IsCustom => Period == PeriodPreset.Custom;
/// <summary>The page defaults as a query: what a page shows with no analysis keys in its URL.</summary>
public static AnalysisQuery Default(AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(defaults);
return new AnalysisQuery(defaults.Period, null, null, defaults.Bucket, defaults.Comparison, defaults.Metric, defaults.Scope, []);
}
/// <summary>
/// Parses the analysis keys of a URL — absolute (<see cref="Microsoft.AspNetCore.Components.NavigationManager.Uri"/>),
/// base-relative, or a query string starting with <c>?</c>. Anything without a <c>?</c> has no keys.
/// </summary>
public static AnalysisQuery Parse(string? uriOrQuery, AnalysisDefaults defaults) =>
Parse(QueryHelpers.ParseQuery(QueryOf(uriOrQuery)), defaults);
/// <summary>Parses the analysis keys of <paramref name="uri"/>.</summary>
public static AnalysisQuery Parse(Uri uri, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(uri);
return Parse(uri.IsAbsoluteUri ? uri.Query : uri.OriginalString, defaults);
}
/// <summary>Parses the analysis keys of a query collection (<see cref="Microsoft.AspNetCore.Http.IQueryCollection"/>, a parsed query).</summary>
public static AnalysisQuery Parse(IEnumerable<KeyValuePair<string, StringValues>> query, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(defaults);
var keys = new Dictionary<string, StringValues>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, values) in query)
{
if (key is not null)
{
keys[key] = keys.TryGetValue(key, out var existing) ? StringValues.Concat(existing, values) : values;
}
}
var reader = new Reader(keys);
var notices = new List<AnalysisQueryNotice>();
var (period, from, to) = ParsePeriod(reader, defaults, notices);
var bucket = ParseToken(reader, AnalysisUrlKeys.Bucket, defaults.Bucket, AnalysisTokens.TryParseBucket, AnalysisQueryNoticeKind.InvalidBucket, notices);
var comparison = ParseComparison(reader, defaults, notices);
var metric = ParseMetric(reader, defaults, notices);
var scope = ParseScope(reader, defaults, notices);
return new AnalysisQuery(period, from, to, bucket, comparison, metric, scope, notices);
}
/// <summary>This query with a preset period.</summary>
/// <exception cref="ArgumentException"><see cref="PeriodPreset.Custom"/>: use <see cref="WithCustomRange"/>.</exception>
public AnalysisQuery WithPeriod(PeriodPreset preset)
{
if (preset == PeriodPreset.Custom || !Enum.IsDefined(preset))
{
throw new ArgumentException("A custom period needs its dates; use WithCustomRange.", nameof(preset));
}
return new AnalysisQuery(preset, null, null, Bucket, Comparison, Metric, Scope, []);
}
/// <summary>This query with a custom range of local days, both inclusive.</summary>
/// <exception cref="ArgumentException">The range fails <see cref="PeriodResolver.IsValidCustomRange"/>: check it first (the toolbar applies a range only once it is valid).</exception>
public AnalysisQuery WithCustomRange(DateOnly first, DateOnly last)
{
if (!PeriodResolver.IsValidCustomRange(first, last))
{
throw new ArgumentException("A custom range needs a first and last day in order, within the supported dates.", nameof(first));
}
return new AnalysisQuery(PeriodPreset.Custom, first, last, Bucket, Comparison, Metric, Scope, []);
}
public AnalysisQuery WithBucket(BucketSize bucket) =>
Enum.IsDefined(bucket)
? new AnalysisQuery(Period, From, To, bucket, Comparison, Metric, Scope, [])
: throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "Unknown bucket size.");
/// <exception cref="ArgumentException">A <see cref="ComparisonKind.Year"/> comparison without its year, which no URL can hold.</exception>
public AnalysisQuery WithComparison(ComparisonRequest comparison)
{
ArgumentNullException.ThrowIfNull(comparison);
if (comparison.Kind == ComparisonKind.Year && comparison.Year is null)
{
throw new ArgumentException("A year comparison needs its year.", nameof(comparison));
}
return new AnalysisQuery(Period, From, To, Bucket, comparison, Metric, Scope, []);
}
/// <summary>This query with a metric; null for the scope's natural one.</summary>
public AnalysisQuery WithMetric(AnalysisMetric? metric) => new(Period, From, To, Bucket, Comparison, metric, Scope, []);
public AnalysisQuery WithScope(QueryScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
return new AnalysisQuery(Period, From, To, Bucket, Comparison, Metric, scope, []);
}
/// <summary>
/// The URL parameters of this query, in canonical order and tokens, leaving out every key equal to
/// <paramref name="defaults"/> (the target page's) and every part not in <paramref name="parts"/>.
/// </summary>
public IReadOnlyList<KeyValuePair<string, string>> ToQueryParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All)
{
ArgumentNullException.ThrowIfNull(defaults);
var list = new List<KeyValuePair<string, string>>(6);
if (parts.HasFlag(AnalysisQueryParts.Scope) && !Scope.Equals(defaults.Scope))
{
list.Add(new(AnalysisUrlKeys.Scope, Scope.Token));
if (Scope.Kind == QueryScopeKind.Meters)
{
list.Add(new(AnalysisUrlKeys.Ids, string.Join(',', Scope.MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture)))));
}
else if (Scope.Id is { } id)
{
list.Add(new(AnalysisUrlKeys.Id, id.ToString(CultureInfo.InvariantCulture)));
}
}
if (parts.HasFlag(AnalysisQueryParts.Metric) && Metric is { } metric && metric != defaults.Metric)
{
list.Add(new(AnalysisUrlKeys.Metric, AnalysisMetrics.Format(metric)));
}
if (parts.HasFlag(AnalysisQueryParts.Period))
{
if (IsCustom)
{
// from/to imply custom, so the preset key is left out.
list.Add(new(AnalysisUrlKeys.From, AnalysisTokens.FormatDate(From!.Value)));
list.Add(new(AnalysisUrlKeys.To, AnalysisTokens.FormatDate(To!.Value)));
}
else if (Period != defaults.Period)
{
list.Add(new(AnalysisUrlKeys.Period, AnalysisTokens.Format(Period)));
}
}
if (parts.HasFlag(AnalysisQueryParts.Bucket) && Bucket != defaults.Bucket)
{
list.Add(new(AnalysisUrlKeys.Bucket, AnalysisTokens.Format(Bucket)));
}
if (parts.HasFlag(AnalysisQueryParts.Comparison) && !Comparison.Equals(defaults.Comparison))
{
list.Add(new(AnalysisUrlKeys.Compare, AnalysisTokens.Format(Comparison)));
}
return list;
}
/// <summary>
/// Every analysis key of <paramref name="parts"/> for
/// <see cref="Microsoft.AspNetCore.Components.NavigationManager.GetUriWithQueryParameters(IReadOnlyDictionary{string, object?})"/>:
/// the value to write, or null to remove a key that equals the default — so updating the current page's URL keeps
/// its other keys (<c>tab</c>) and drops stale ones (<c>from</c>/<c>to</c> after leaving a custom range).
/// </summary>
public IReadOnlyDictionary<string, object?> ToNavigationParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All)
{
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var key in KeysOf(parts))
{
result[key] = null;
}
foreach (var (key, value) in ToQueryParameters(defaults, parts))
{
result[key] = value;
}
return result;
}
/// <summary>
/// <paramref name="url"/> with this query's parameters appended after its own (D-47: existing keys first), leaving
/// out what equals <paramref name="defaults"/> — the target page's. Links carry <see cref="AnalysisQueryParts.Carry"/>
/// by default: the target's route names its own scope.
/// </summary>
public string AppendTo(string url, AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.Carry)
{
ArgumentNullException.ThrowIfNull(url);
var parameters = ToQueryParameters(defaults, parts);
if (parameters.Count == 0)
{
return url;
}
var builder = new StringBuilder(url);
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
foreach (var (key, value) in parameters)
{
builder.Append(separator).Append(key).Append('=').Append(Escape(value));
separator = '&';
}
return builder.ToString();
}
/// <summary>
/// Resolves the period once, against the captured <paramref name="now"/> in the instance <paramref name="zone"/>
/// (D-01, D-03). <c>all</c> spans <paramref name="availability"/> (D-19) — the quantity or cost scope's, whichever
/// the page shows — and is the "no history" range without it.
/// </summary>
public ResolvedPeriod Resolve(DateTimeOffset now, TimeZoneInfo zone, AvailableRange? availability = null)
{
ArgumentNullException.ThrowIfNull(zone);
return Period switch
{
PeriodPreset.Custom => PeriodResolver.Resolve(PeriodPreset.Custom, From, To, now, zone),
PeriodPreset.AllHistory => PeriodResolver.Resolve(
PeriodPreset.AllHistory, null, null, now, zone, availability?.FirstDay, availability?.LastDay),
_ => PeriodResolver.Resolve(Period, null, null, now, zone),
};
}
/// <summary>
/// The quantity request of this query over <paramref name="period"/> (bucket and comparison included), or null for
/// a category scope, which is analysed by cost.
/// </summary>
/// <param name="period">The period from <see cref="Resolve"/>.</param>
/// <param name="includeMeterSeries">For a type or portfolio: also one series per meter ("individual meters").</param>
public AnalysisRequest? ToAnalysisRequest(ResolvedPeriod period, bool includeMeterSeries = false)
{
ArgumentNullException.ThrowIfNull(period);
return Scope.ToAnalysisScope() is { } scope
? new AnalysisRequest(scope, period) { Bucket = Bucket, Comparison = Comparison, IncludeMeterSeries = includeMeterSeries }
: null;
}
/// <summary>
/// The cost requests of this query over <paramref name="period"/>: one for the portfolio, a type, a meter or a
/// category, one per meter for a selection.
/// </summary>
/// <param name="period">The period from <see cref="Resolve"/>.</param>
/// <param name="plan">
/// Buckets to price in — a quantity result's <see cref="AnalysisResult.Plan"/>, or the first cost result's for the
/// rest of a selection — so cost and quantity share their buckets; null lets the cost reader plan from
/// <see cref="Bucket"/>.
/// </param>
/// <param name="includeCategories">For the portfolio: also the category composition (D-42).</param>
public IReadOnlyList<CostAnalysisRequest> ToCostRequests(ResolvedPeriod period, BucketPlan? plan = null, bool includeCategories = false)
{
ArgumentNullException.ThrowIfNull(period);
return
[
.. Scope.ToCostScopes().Select(scope => new CostAnalysisRequest(scope, period)
{
Bucket = Bucket,
Plan = plan,
IncludeCategories = includeCategories && scope.Kind == CostScopeKind.Portfolio,
}),
];
}
/// <summary>
/// The cost request for this query's comparison (D-06) of an already priced <paramref name="current"/> request: the
/// comparison period, priced in the images of the current buckets (<see cref="ComparisonResolver.PairBuckets"/>), so
/// bucket i of the result compares with bucket i of the current one. The cost reader has no comparison of its own;
/// quantities get theirs from the analysis reader (<see cref="AnalysisSeries.Comparison"/>).
/// </summary>
/// <param name="current">The current cost request (its scope and period).</param>
/// <param name="currentPlan">The plan the current result was priced in (<see cref="CostAnalysis.Plan"/>).</param>
public CostComparisonRequest ToCostComparison(CostAnalysisRequest current, BucketPlan currentPlan)
{
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(currentPlan);
var resolution = ComparisonResolver.Resolve(current.Period, Comparison);
if (!resolution.IsApplicable)
{
return new CostComparisonRequest(resolution, null, []);
}
var pairs = ComparisonResolver.PairBuckets(current.Period, resolution.Period, currentPlan.Buckets);
var plan = new BucketPlan(currentPlan.Size, currentPlan.Size, [.. pairs.Select(p => p.Comparison)], pairs.Count, Refused: false, Suggested: null);
var request = new CostAnalysisRequest(current.Scope, resolution.Period.ToResolvedPeriod(current.Period))
{
Plan = plan,
MaxPoints = current.MaxPoints,
IncludeCategories = current.IncludeCategories,
};
return new CostComparisonRequest(resolution, request, pairs);
}
public bool Equals(AnalysisQuery? other) =>
other is not null
&& Period == other.Period
&& From == other.From
&& To == other.To
&& Bucket == other.Bucket
&& Comparison.Equals(other.Comparison)
&& Metric == other.Metric
&& Scope.Equals(other.Scope);
public override bool Equals(object? obj) => Equals(obj as AnalysisQuery);
public override int GetHashCode() => HashCode.Combine(Period, From, To, Bucket, Comparison, Metric, Scope);
public static bool operator ==(AnalysisQuery? left, AnalysisQuery? right) => left is null ? right is null : left.Equals(right);
public static bool operator !=(AnalysisQuery? left, AnalysisQuery? right) => !(left == right);
/// <summary>Every key written, defaults included — for logs.</summary>
public override string ToString()
{
var period = IsCustom
? AnalysisTokens.FormatDate(From!.Value) + ".." + AnalysisTokens.FormatDate(To!.Value)
: AnalysisTokens.Format(Period);
var metric = Metric is { } m ? AnalysisMetrics.Format(m) : "natural";
return string.Create(
CultureInfo.InvariantCulture,
$"{Scope} {metric} {period} {AnalysisTokens.Format(Bucket)} {AnalysisTokens.Format(Comparison)}");
}
private static (PeriodPreset Period, DateOnly? From, DateOnly? To) ParsePeriod(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
{
var periodToken = reader.First(AnalysisUrlKeys.Period);
var fromToken = reader.First(AnalysisUrlKeys.From);
var toToken = reader.First(AnalysisUrlKeys.To);
if (periodToken is not null)
{
if (!AnalysisTokens.TryParsePeriod(periodToken, out var preset))
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidPeriod, AnalysisUrlKeys.Period, periodToken));
return (defaults.Period, null, null);
}
// Beside another preset, from/to mean nothing and are ignored.
if (preset != PeriodPreset.Custom)
{
return (preset, null, null);
}
}
else if (fromToken is null && toToken is null)
{
return (defaults.Period, null, null);
}
if (AnalysisTokens.TryParseCustomRange(fromToken, toToken, out var first, out var last))
{
return (PeriodPreset.Custom, first, last);
}
notices.Add(new AnalysisQueryNotice(
AnalysisQueryNoticeKind.InvalidRange, AnalysisUrlKeys.From + "/" + AnalysisUrlKeys.To, (fromToken ?? string.Empty) + "/" + (toToken ?? string.Empty)));
return (defaults.Period, null, null);
}
private static ComparisonRequest ParseComparison(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
{
var token = reader.First(AnalysisUrlKeys.Compare);
if (token is null)
{
return defaults.Comparison;
}
if (AnalysisTokens.TryParseComparison(token, out var comparison))
{
return comparison;
}
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidComparison, AnalysisUrlKeys.Compare, token));
return defaults.Comparison;
}
private static AnalysisMetric? ParseMetric(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
{
var token = reader.First(AnalysisUrlKeys.Metric);
if (token is null)
{
return defaults.Metric;
}
if (AnalysisMetrics.TryParse(token, out var metric))
{
return metric;
}
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidMetric, AnalysisUrlKeys.Metric, token));
return defaults.Metric;
}
private delegate bool TokenParser<T>(string? token, out T value);
private static T ParseToken<T>(
Reader reader, string key, T fallback, TokenParser<T> parse, AnalysisQueryNoticeKind invalid, List<AnalysisQueryNotice> notices)
{
var token = reader.First(key);
if (token is null)
{
return fallback;
}
if (parse(token, out var value))
{
return value;
}
notices.Add(new AnalysisQueryNotice(invalid, key, token));
return fallback;
}
private static QueryScope ParseScope(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
{
// Without a scope key, ids mean nothing: the route (or the page default) names the scope.
var token = reader.First(AnalysisUrlKeys.Scope);
if (token is null)
{
return defaults.Scope;
}
if (!QueryScope.TryParseKind(token, out var kind))
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token));
return defaults.Scope;
}
switch (kind)
{
case QueryScopeKind.Portfolio:
return QueryScope.Portfolio;
case QueryScopeKind.Meters:
return ParseSelection(reader, token, notices) ?? defaults.Scope;
default:
var idToken = reader.First(AnalysisUrlKeys.Id);
if (TryParseId(idToken, out var id))
{
return kind switch
{
QueryScopeKind.EnergyType => QueryScope.ForEnergyType(id),
QueryScopeKind.Category => QueryScope.ForCategory(id),
_ => QueryScope.ForMeter(id),
};
}
notices.Add(idToken is null
? new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token)
: new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Id, idToken));
return defaults.Scope;
}
}
/// <summary>
/// <c>ids=3,5,9</c> (or repeated <c>ids</c>, or a single <c>id</c>): the valid ids in order, distinct, at most
/// <see cref="AnalysisLimits.MaxSeries"/>; null when none is valid.
/// </summary>
private static QueryScope? ParseSelection(Reader reader, string scopeToken, List<AnalysisQueryNotice> notices)
{
var tokens = reader.All(AnalysisUrlKeys.Ids)
.SelectMany(v => v.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
.ToList();
if (tokens.Count == 0 && reader.First(AnalysisUrlKeys.Id) is { } single)
{
tokens.Add(single.Trim());
}
var ids = new List<int>();
var invalid = new List<string>();
foreach (var item in tokens)
{
if (!TryParseId(item, out var id))
{
invalid.Add(item);
}
else if (!ids.Contains(id))
{
ids.Add(id);
}
}
if (invalid.Count > 0)
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Ids, string.Join(',', invalid)));
}
if (ids.Count > AnalysisLimits.MaxSeries)
{
notices.Add(new AnalysisQueryNotice(
AnalysisQueryNoticeKind.TooManyMeters, AnalysisUrlKeys.Ids, ids.Count.ToString(CultureInfo.InvariantCulture)));
ids = ids.Take(AnalysisLimits.MaxSeries).ToList();
}
if (ids.Count == 0)
{
if (invalid.Count == 0)
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, scopeToken));
}
return null;
}
return QueryScope.ForMeters(ids);
}
private static bool TryParseId(string? token, out int id)
{
id = 0;
return token is not null
&& int.TryParse(token.AsSpan().Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out id)
&& id > 0;
}
private static IEnumerable<string> KeysOf(AnalysisQueryParts parts)
{
if (parts.HasFlag(AnalysisQueryParts.Scope))
{
yield return AnalysisUrlKeys.Scope;
yield return AnalysisUrlKeys.Id;
yield return AnalysisUrlKeys.Ids;
}
if (parts.HasFlag(AnalysisQueryParts.Metric))
{
yield return AnalysisUrlKeys.Metric;
}
if (parts.HasFlag(AnalysisQueryParts.Period))
{
yield return AnalysisUrlKeys.Period;
yield return AnalysisUrlKeys.From;
yield return AnalysisUrlKeys.To;
}
if (parts.HasFlag(AnalysisQueryParts.Bucket))
{
yield return AnalysisUrlKeys.Bucket;
}
if (parts.HasFlag(AnalysisQueryParts.Comparison))
{
yield return AnalysisUrlKeys.Compare;
}
}
/// <summary>The query part of a URL (from its <c>?</c>, without a fragment), or empty.</summary>
private static string QueryOf(string? uriOrQuery)
{
if (string.IsNullOrEmpty(uriOrQuery))
{
return string.Empty;
}
var start = uriOrQuery.IndexOf('?', StringComparison.Ordinal);
if (start < 0)
{
return string.Empty;
}
var end = uriOrQuery.IndexOf('#', start);
return end < 0 ? uriOrQuery[start..] : uriOrQuery[start..end];
}
/// <summary>Escapes a value, keeping the <c>:</c> of <c>year:2025</c> and the commas of an id list readable.</summary>
private static string Escape(string value) =>
Uri.EscapeDataString(value).Replace("%3A", ":", StringComparison.Ordinal).Replace("%2C", ",", StringComparison.Ordinal);
/// <summary>Case-insensitive access to a parsed query: the first non-blank value of a key, or all of them.</summary>
private sealed class Reader(Dictionary<string, StringValues> keys)
{
public string? First(string key)
{
if (!keys.TryGetValue(key, out var values))
{
return null;
}
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
public IEnumerable<string> All(string key) =>
keys.TryGetValue(key, out var values) ? values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!) : [];
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Globalization;
namespace MeterVault.App.Analysis;
/// <summary>Why part of an analysis URL was not used as written (D-02: an invalid token falls back to the default with a notice).</summary>
public enum AnalysisQueryNoticeKind
{
/// <summary><c>period</c> is not a known preset.</summary>
InvalidPeriod,
/// <summary>A custom range whose <c>from</c>/<c>to</c> are missing, malformed, out of order or outside the supported dates.</summary>
InvalidRange,
/// <summary><c>bucket</c> is not a known size.</summary>
InvalidBucket,
/// <summary><c>compare</c> is not a known comparison.</summary>
InvalidComparison,
/// <summary><c>metric</c> is not a known metric.</summary>
InvalidMetric,
/// <summary><c>scope</c> is not a known scope, or names no usable id.</summary>
InvalidScope,
/// <summary>An <c>id</c>/<c>ids</c> entry is not a positive whole number.</summary>
InvalidId,
/// <summary>More meters than can be charted side by side were selected; the first ones are kept.</summary>
TooManyMeters,
}
/// <summary>
/// One part of an analysis URL that was not used as written: what was wrong, under which key, and the raw value. The
/// page shows it (localized through <see cref="Localization.DisplayNames"/>); the CSV export answers it with a 400.
/// </summary>
/// <param name="Kind">What was wrong.</param>
/// <param name="Key">The URL key (<c>period</c>, <c>ids</c>, …).</param>
/// <param name="Value">The raw value as it appeared (data, never shown untrusted as markup).</param>
public sealed record AnalysisQueryNotice(AnalysisQueryNoticeKind Kind, string Key, string? Value)
{
/// <summary>A short invariant English sentence, for logs and the export's 400 responses.</summary>
public string Describe() => Kind switch
{
AnalysisQueryNoticeKind.InvalidPeriod => Quote("Unknown period"),
AnalysisQueryNoticeKind.InvalidRange => Quote("Invalid custom range (from/to must be yyyy-MM-dd dates in order)"),
AnalysisQueryNoticeKind.InvalidBucket => Quote("Unknown bucket"),
AnalysisQueryNoticeKind.InvalidComparison => Quote("Unknown comparison"),
AnalysisQueryNoticeKind.InvalidMetric => Quote("Unknown metric"),
AnalysisQueryNoticeKind.InvalidScope => Quote("Unknown or incomplete scope"),
AnalysisQueryNoticeKind.InvalidId => Quote("Invalid id"),
AnalysisQueryNoticeKind.TooManyMeters => string.Create(
CultureInfo.InvariantCulture, $"At most {Infrastructure.Analysis.AnalysisLimits.MaxSeries} meters can be compared ('{Key}')."),
_ => Quote("Invalid value"),
};
private string Quote(string text) => string.Create(CultureInfo.InvariantCulture, $"{text}: {Key}='{Value}'.");
}
+324
View File
@@ -0,0 +1,324 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Infrastructure.Analysis;
namespace MeterVault.App.Analysis;
/// <summary>One formatted figure of a table: the number, its text (with unit or currency, "—" when unknown) and its status.</summary>
public sealed record TableFigure(double? Value, string Text, FigureStatus Status)
{
/// <summary>A quantity with its unit.</summary>
public static TableFigure Of(BucketValue value, string? unit, Func<int, string?>? meterName = null)
{
var status = FigureText.Of(value, meterName);
var number = status.IsKnown ? value.Value : null;
return new TableFigure(number, Format.Quantity(number, unit), status);
}
/// <summary>A cost in <paramref name="currency"/>.</summary>
public static TableFigure Of(CostAmount amount, string currency)
{
var status = FigureText.Of(amount);
var number = status.IsKnown ? amount.Cost : null;
return new TableFigure(number, Format.Money(number, currency), status);
}
}
/// <summary>
/// A series of the analysis table (brief §7.2): its values per bucket and in total, and optionally its cost and its
/// comparison — each formatted once, in the reader's culture, with its status in words.
/// </summary>
public sealed record AnalysisTableSeries
{
private AnalysisTableSeries(string key, string name, IReadOnlyList<TableFigure> values, TableFigure? total, bool isMoney)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(name);
Key = key;
Name = name;
Values = values;
Total = total;
IsMoney = isMoney;
}
public string Key { get; init; }
/// <summary>The column header (a meter's name is user data, never translated).</summary>
public string Name { get; init; }
/// <summary>One figure per bucket.</summary>
public IReadOnlyList<TableFigure> Values { get; init; }
/// <summary>The period total from the reader; null to show none (it is never added up here: a formula may not be additive).</summary>
public TableFigure? Total { get; init; }
/// <summary>True when the values are money, so no separate cost column applies.</summary>
public bool IsMoney { get; init; }
/// <summary>The cost per bucket, when priced (a meter's cost by its rule).</summary>
public IReadOnlyList<TableFigure>? Costs { get; init; }
public TableFigure? CostTotal { get; init; }
/// <summary>The comparison per paired bucket (A-10), when one was requested.</summary>
public IReadOnlyList<TableFigure>? Comparison { get; init; }
public TableFigure? ComparisonTotal { get; init; }
/// <summary>The change over the matched coverage (D-07) for the total row; computed from the totals when absent.</summary>
public Change? TotalChange { get; init; }
/// <summary>Formats the size of a change (with unit or currency).</summary>
public Func<double, string> FormatDifference { get; init; } = v => Format.Number(v, 2);
/// <summary>Whether a rise is good news (D-08).</summary>
public ChangePolarity Polarity { get; init; } = ChangePolarity.HigherIsWorse;
/// <summary>False when the buckets do not add up to the total (a ratio, a formula with a constant, D-27).</summary>
public bool IsAdditive { get; init; } = true;
/// <summary>A quantity series with its unit.</summary>
/// <param name="key">A stable key.</param>
/// <param name="name">The column header.</param>
/// <param name="unit">The unit of every value.</param>
/// <param name="values">One value per bucket.</param>
/// <param name="total">The period total; null to show none.</param>
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
public static AnalysisTableSeries ForValues(
string key, string name, string? unit, IReadOnlyList<BucketValue> values, BucketValue? total = null, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(values);
return new AnalysisTableSeries(
key, name, [.. values.Select(v => TableFigure.Of(v, unit, meterName))], total is null ? null : TableFigure.Of(total, unit, meterName), isMoney: false)
{
FormatDifference = v => Format.Quantity(v, unit),
};
}
/// <summary>A cost series (metric cost, a category): the values are money.</summary>
public static AnalysisTableSeries ForCosts(string key, string name, string currency, IReadOnlyList<CostAmount> amounts, CostAmount? total = null)
{
ArgumentNullException.ThrowIfNull(amounts);
return new AnalysisTableSeries(key, name, [.. amounts.Select(a => TableFigure.Of(a, currency))], total is null ? null : TableFigure.Of(total, currency), isMoney: true)
{
FormatDifference = v => Format.Money(v, currency),
};
}
/// <summary>
/// A reader series with its total, polarity, additivity and — when one was read — its comparison and the change over
/// the matched coverage.
/// </summary>
/// <param name="series">The series.</param>
/// <param name="name">The column header; the meter's name or the measure's wording by default.</param>
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
public static AnalysisTableSeries ForSeries(AnalysisSeries series, string? name = null, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(series);
var table = ForValues(series.Key.Id, name ?? AnalysisChartSeries.NameOf(series), series.Unit, series.Values, series.Total, meterName) with
{
Polarity = ChangePolarities.For(series.Kind),
IsAdditive = series.IsAdditive,
};
return series.Comparison is { } comparison
? table.WithComparison(comparison.Values, comparison.Total, series.Unit, comparison.Change, meterName)
: table;
}
/// <summary>This series with a comparison in the same unit.</summary>
public AnalysisTableSeries WithComparison(
IReadOnlyList<BucketValue> values, BucketValue? total, string? unit, Change? totalChange = null, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(values);
return this with
{
Comparison = [.. values.Select(v => TableFigure.Of(v, unit, meterName))],
ComparisonTotal = total is null ? null : TableFigure.Of(total, unit, meterName),
TotalChange = totalChange,
};
}
/// <summary>This (money) series with its comparison priced in the paired buckets.</summary>
public AnalysisTableSeries WithComparisonCosts(IReadOnlyList<CostAmount> amounts, CostAmount? total, string currency)
{
ArgumentNullException.ThrowIfNull(amounts);
return this with
{
Comparison = [.. amounts.Select(a => TableFigure.Of(a, currency))],
ComparisonTotal = total is null ? null : TableFigure.Of(total, currency),
};
}
/// <summary>This quantity series with its cost per bucket (a meter's cost by its rule).</summary>
public AnalysisTableSeries WithCosts(IReadOnlyList<CostAmount> amounts, CostAmount? total, string currency)
{
ArgumentNullException.ThrowIfNull(amounts);
return this with
{
Costs = [.. amounts.Select(a => TableFigure.Of(a, currency))],
CostTotal = total is null ? null : TableFigure.Of(total, currency),
};
}
}
/// <summary>What a table column shows.</summary>
public enum AnalysisTableColumnKind
{
Value,
Status,
Cost,
CostStatus,
Comparison,
Change,
}
/// <summary>A column: its series, what it shows, its header, and (with several series) the series it belongs to.</summary>
public sealed record AnalysisTableColumn(string SeriesKey, AnalysisTableColumnKind Kind, string Header, string? SubHeader)
{
/// <summary>Numbers are right-aligned.</summary>
public bool IsNumeric => Kind is AnalysisTableColumnKind.Value or AnalysisTableColumnKind.Cost
or AnalysisTableColumnKind.Comparison or AnalysisTableColumnKind.Change;
}
/// <summary>A cell: its text, an optional second line (the reason, the compared bucket), a CSS class and whether it is unknown.</summary>
public sealed record AnalysisTableCell(string Text, string? Secondary = null, string? CssClass = null, bool IsUnknown = false);
/// <summary>A row: one bucket (or the total), its label and one cell per column.</summary>
public sealed record AnalysisTableRow(AnalysisBucket? Bucket, string Label, IReadOnlyList<AnalysisTableCell> Cells, bool IsTotal, bool IsQualified);
/// <summary>
/// The analysis table (brief §7.2) as data: one row per bucket and a total row, each series with its value, status in
/// words, optional cost and cost status, optional comparison and change. It is the chart's accessible alternative, so it
/// says in words what the chart only marks.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed record AnalysisTableModel(IReadOnlyList<AnalysisTableColumn> Columns, IReadOnlyList<AnalysisTableRow> Rows)
{
/// <summary>Builds the table.</summary>
/// <param name="buckets">The buckets, oldest first.</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>
public static AnalysisTableModel Build(
IReadOnlyList<AnalysisBucket> buckets,
IReadOnlyList<AnalysisTableSeries> series,
IReadOnlyList<BucketPair>? pairs = null,
bool includeTotal = true)
{
ArgumentNullException.ThrowIfNull(buckets);
ArgumentNullException.ThrowIfNull(series);
var several = series.Count > 1;
var columns = new List<AnalysisTableColumn>();
foreach (var item in series)
{
var sub = several ? item.Name : null;
columns.Add(new(item.Key, AnalysisTableColumnKind.Value, item.Name.Length > 0 ? item.Name : Strings.Common_Value, null));
columns.Add(new(item.Key, AnalysisTableColumnKind.Status, Strings.Common_Status, sub));
if (item.Costs is not null)
{
columns.Add(new(item.Key, AnalysisTableColumnKind.Cost, Strings.AnalysisTable_Cost, sub));
columns.Add(new(item.Key, AnalysisTableColumnKind.CostStatus, Strings.AnalysisTable_PriceCoverage, sub));
}
if (item.Comparison is not null)
{
columns.Add(new(item.Key, AnalysisTableColumnKind.Comparison, Strings.AnalysisTable_Comparison, sub));
columns.Add(new(item.Key, AnalysisTableColumnKind.Change, Strings.AnalysisTable_Change, sub));
}
}
var labels = AnalysisChartPlan.BucketLabels(buckets);
var rows = new List<AnalysisTableRow>(buckets.Count + 1);
for (var i = 0; i < buckets.Count; i++)
{
var cells = new List<AnalysisTableCell>(columns.Count);
var qualified = false;
var pairLabel = pairs is not null && i < pairs.Count ? Format.BucketLabel(pairs[i].Comparison, includeYear: true) : null;
foreach (var item in series)
{
var value = At(item.Values, i);
qualified |= value.Status.IsQualified;
AddCells(cells, item, value, AtOrNull(item.Costs, i), AtOrNull(item.Comparison, i), pairLabel, null);
}
rows.Add(new AnalysisTableRow(buckets[i], labels[i], cells, IsTotal: false, qualified));
}
if (includeTotal && buckets.Count > 0)
{
var cells = new List<AnalysisTableCell>(columns.Count);
var qualified = false;
foreach (var item in series)
{
var total = item.Total ?? Unknown;
qualified |= item.Total is not null && total.Status.IsQualified;
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));
}
return new AnalysisTableModel(columns, rows);
}
/// <summary>Half a cent: a money difference that displays as zero is no change.</summary>
private const double MoneyTolerance = 0.005;
/// <summary>A figure nothing is known about.</summary>
private static TableFigure Unknown { get; } =
new(null, Format.Unknown, new FigureStatus(false, false, true, BucketStatus.Missing.Display(), null, string.Empty));
private static TableFigure At(IReadOnlyList<TableFigure> figures, int index) => index < figures.Count ? figures[index] : Unknown;
private static TableFigure? AtOrNull(IReadOnlyList<TableFigure>? figures, int index) => figures is null ? null : At(figures, index);
private static void AddCells(
List<AnalysisTableCell> cells,
AnalysisTableSeries series,
TableFigure value,
TableFigure? cost,
TableFigure? comparison,
string? pairLabel,
Change? change)
{
cells.Add(new AnalysisTableCell(value.Text, null, value.Status.IsQualified ? "mv-qualified" : null, value.Value is null));
cells.Add(new AnalysisTableCell(value.Status.Summary, value.Status.Detail));
if (series.Costs is not null)
{
var figure = cost ?? Unknown;
cells.Add(new AnalysisTableCell(figure.Text, null, figure.Status.IsQualified ? "mv-qualified" : null, figure.Value is null));
cells.Add(new AnalysisTableCell(figure.Status.Status, figure.Status.Detail));
}
if (series.Comparison is not null)
{
var figure = comparison ?? Unknown;
cells.Add(new AnalysisTableCell(figure.Text, pairLabel, figure.Status.IsQualified ? "mv-qualified" : null, figure.Value is null));
var stated = change is { IsAvailable: true } ? change : ChangeOf(value, figure, series.IsMoney ? MoneyTolerance : Change.Tolerance);
var polarity = series.IsMoney ? ChangePolarities.ForCost(value.Value, figure.Value) : series.Polarity;
var tone = ChangeDisplay.Tone(stated, polarity);
cells.Add(new AnalysisTableCell(Format.ChangeText(stated, series.FormatDifference), null, ChangeDisplay.CssClass(tone), !stated.IsAvailable));
}
}
/// <summary>The change between two complete figures; unavailable when either is incomplete or unknown.</summary>
private static Change ChangeOf(TableFigure current, TableFigure previous, double tolerance) =>
current.Status.IsComplete && previous.Status.IsComplete
? Change.Between(current.Value, previous.Value, tolerance)
: Change.Unavailable;
}
+462
View File
@@ -0,0 +1,462 @@
using System.Globalization;
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
namespace MeterVault.App.Analysis;
/// <summary>How urgent an attention item is. Shown with an icon and words, never colour alone.</summary>
public enum AttentionSeverity
{
/// <summary>Worth knowing (analysis being prepared, rows after now, a calculation to confirm).</summary>
Info,
/// <summary>A figure is incomplete until it is fixed (a missing price, a stale source).</summary>
Warning,
/// <summary>A figure cannot be computed until it is fixed (an invalid calculation).</summary>
Error,
}
/// <summary>One attention item (D-53): a localized one-liner and at most one targeted action.</summary>
/// <param name="Key">An invariant identity (kind, meter, text) — duplicates from the quantity and the cost reader collapse.</param>
/// <param name="Severity">How urgent it is.</param>
/// <param name="Text">The one-liner, in the reader's language; user data (names) as it is.</param>
/// <param name="ActionText">The action's label, or null when there is nothing to do here.</param>
/// <param name="ActionHref">Where the action leads.</param>
public sealed record AttentionItem(string Key, AttentionSeverity Severity, string Text, string? ActionText, string? ActionHref);
/// <summary>
/// The names attention items speak of: meters, energy types and cost categories by id (user data, never translated), with a neutral
/// fallback ("Meter #12") for an id nobody named.
/// </summary>
public sealed class AttentionNames
{
private readonly IReadOnlyDictionary<int, string> _meters;
private readonly IReadOnlyDictionary<int, string> _energyTypes;
private readonly IReadOnlyDictionary<int, string> _categories;
public AttentionNames(
IReadOnlyDictionary<int, string>? meters = null,
IReadOnlyDictionary<int, string>? energyTypes = null,
IReadOnlyDictionary<int, string>? categories = null)
{
_meters = meters ?? new Dictionary<int, string>();
_energyTypes = energyTypes ?? new Dictionary<int, string>();
_categories = categories ?? new Dictionary<int, string>();
}
/// <summary>
/// The meter names a quantity result and a cost result carry — series, classification, virtual sources at any depth,
/// priced lines — plus the given energy type names.
/// </summary>
public static AttentionNames From(AnalysisResult? result, CostAnalysis? costs = null, IReadOnlyDictionary<int, string>? energyTypes = null)
{
var meters = new Dictionary<int, string>();
if (result is not null)
{
foreach (var series in result.Series)
{
Add(meters, series.MeterId, series.Name);
AddContributions(meters, series.Contributions);
}
foreach (var entry in result.Classification)
{
Add(meters, entry.MeterId, entry.Name);
}
}
var categories = new Dictionary<int, string>();
if (costs is not null)
{
foreach (var line in costs.Lines)
{
Add(meters, line.MeterId, line.Name);
}
foreach (var category in (costs.Composition?.Categories ?? []).Concat(costs.Category is { } own ? [own] : []))
{
Add(categories, category.CategoryId, category.Name);
}
}
return new AttentionNames(meters, energyTypes, categories);
}
/// <summary>The meter's name, or "Meter #id".</summary>
public string Meter(int? id) =>
id is { } key && _meters.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
? name
: Loc.F(Strings.Attention_MeterFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
/// <summary>The energy type's name, or "Energy type #id".</summary>
public string EnergyType(int? id) =>
id is { } key && _energyTypes.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
? name
: Loc.F(Strings.Attention_EnergyTypeFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
/// <summary>The cost category's name, or "Category #id".</summary>
public string Category(int? id) =>
id is { } key && _categories.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
? name
: Loc.F(Strings.Attention_CategoryFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
/// <summary>A meter id's name for value details, or null when unknown.</summary>
public string? MeterOrNull(int id) => _meters.TryGetValue(id, out var name) ? name : null;
private static void AddContributions(Dictionary<int, string> meters, IReadOnlyList<SeriesContribution> contributions)
{
foreach (var contribution in contributions)
{
Add(meters, contribution.MeterId, contribution.Name);
AddContributions(meters, contribution.Nested);
}
}
private static void Add(Dictionary<int, string> meters, int? id, string? name)
{
if (id is { } key && !string.IsNullOrWhiteSpace(name))
{
meters.TryAdd(key, name);
}
}
}
/// <summary>
/// Turns the readers' attention codes (D-53) — <see cref="AnalysisProblem"/> and <see cref="CostAttention"/> — into
/// one-liners with one targeted action each: a missing price opens the tariff editor prefilled for its scope, component
/// and first uncovered month (D-52); a calculation to fix opens the meter's Calculation tab; a stale source its Sources
/// tab; rows after now its Normalized data around those days; a possible overlap the energy type's Meters tab; a
/// configuration conflict the meter editor. A kind this code does not know still gets its worded kind, without action.
/// </summary>
/// <remarks>
/// Items are ordered by severity (errors first), then as the readers reported them; items with the same text for the same
/// meter collapse (the cost reader repeats the quantity reader's problems).
/// </remarks>
public static class AttentionItems
{
/// <summary>Builds the items.</summary>
/// <param name="problems">The quantity reader's problems (<see cref="AnalysisResult.Problems"/>).</param>
/// <param name="costAttention">The cost reader's items (<see cref="CostAnalysis.Attention"/>) — and its <see cref="CostAnalysis.QuantityProblems"/> go into <paramref name="problems"/>.</param>
/// <param name="names">The names to speak of.</param>
/// <param name="query">The page's analysis state; links into meter pages carry its period.</param>
public static IReadOnlyList<AttentionItem> Build(
IEnumerable<AnalysisProblem>? problems,
IEnumerable<CostAttention>? costAttention,
AttentionNames names,
AnalysisQuery? query = null)
{
ArgumentNullException.ThrowIfNull(names);
var items = new List<AttentionItem>();
foreach (var problem in problems ?? [])
{
if (problem is not null)
{
items.Add(ForProblem(problem, names, query));
}
}
foreach (var attention in costAttention ?? [])
{
if (attention is not null)
{
items.Add(ForCost(attention, names, query));
}
}
var seen = new HashSet<string>(StringComparer.Ordinal);
return
[
.. items
.Select((item, index) => (Item: item, Index: index))
.Where(x => seen.Add(x.Item.Key))
.OrderByDescending(x => x.Item.Severity)
.ThenBy(x => x.Index)
.Select(x => x.Item),
];
}
/// <summary>One quantity problem.</summary>
public static AttentionItem ForProblem(AnalysisProblem problem, AttentionNames names, AnalysisQuery? query = null)
{
ArgumentNullException.ThrowIfNull(problem);
ArgumentNullException.ThrowIfNull(names);
var meter = names.Meter(problem.MeterId);
switch (problem.Kind)
{
case AnalysisProblemKind.AnalysisPending:
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_AnalysisPending, meter));
case AnalysisProblemKind.UnknownMeter:
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_UnknownMeter, meter));
case AnalysisProblemKind.InvalidDefinition:
// With the validator's finding the item says what is wrong (D-26), not only that something is.
var invalid = problem.Virtual is { } finding
? Loc.F(Strings.Attention_InvalidDefinitionBecause, meter, VirtualReason(finding, names))
: Loc.F(Strings.Attention_InvalidDefinition, meter);
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Error, invalid,
Strings.Attention_EditCalculation, CalculationLink(problem.MeterId, query));
case AnalysisProblemKind.MalformedDefinition:
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Error, Loc.F(Strings.Attention_MalformedDefinition, meter),
Strings.Attention_EditCalculation, CalculationLink(problem.MeterId, query));
case AnalysisProblemKind.LegacyDefinition:
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_LegacyDefinition, meter),
Strings.Attention_ConfirmCalculation, CalculationLink(problem.MeterId, query));
case AnalysisProblemKind.LegacyNeedsConfiguration:
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Error, Loc.F(Strings.Attention_LegacyNeedsConfiguration, meter),
Strings.Attention_SetUpCalculation, CalculationLink(problem.MeterId, query));
case AnalysisProblemKind.RecordedAfterNow:
return RecordedAfterNow(problem, meter, query);
case AnalysisProblemKind.StaleSource:
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_StaleSource, meter),
Strings.Attention_CheckSource, MeterLink(problem.MeterId, MeterLinks.TabSources, null, query));
case AnalysisProblemKind.TotalsProblem:
var other = problem.Totals?.OtherMeterId ?? (problem.MeterIds.Count > 0 ? problem.MeterIds[0] : null);
var reason = problem.Totals is { } totals ? TotalsReason(totals) : null;
var text = (other, reason) switch
{
(null, null) => Loc.F(Strings.Attention_TotalsProblem, meter),
(null, { } why) => Loc.F(Strings.Attention_TotalsProblemBecause, meter, why),
({ } id, null) => Loc.F(Strings.Attention_TotalsProblemWith, meter, names.Meter(id)),
({ } id, { } why) => Loc.F(Strings.Attention_TotalsProblemWithBecause, meter, names.Meter(id), why),
};
return Item(
problem.Kind, problem.MeterId, AttentionSeverity.Warning, text,
Strings.Attention_EditMeter, MeterLink(problem.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
case AnalysisProblemKind.PossibleOverlap:
return PossibleOverlap(problem, names, meter, query);
default:
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, problem.MeterId is null ? problem.Kind.Display() : meter + ": " + problem.Kind.Display());
}
}
/// <summary>One cost attention item.</summary>
public static AttentionItem ForCost(CostAttention attention, AttentionNames names, AnalysisQuery? query = null)
{
ArgumentNullException.ThrowIfNull(attention);
ArgumentNullException.ThrowIfNull(names);
var meter = names.Meter(attention.MeterId);
var key = "cost:" + attention.Kind;
switch (attention.Kind)
{
case CostAttentionKind.MissingPrice when attention.Price is { } price:
return MissingPrice(price, names);
case CostAttentionKind.UnverifiedTariffUnit:
return Item(key, attention.MeterId, AttentionSeverity.Info, Strings.Attention_UnverifiedTariffUnit, Strings.Attention_OpenTariffs, TariffLinks.Path);
case CostAttentionKind.ManualCostAfterToday:
return Item(key, null, AttentionSeverity.Info, Loc.F(Strings.Attention_ManualCostAfterToday, attention.ManualCostIds.Count));
case CostAttentionKind.ManualCostCurrency:
return Item(key, null, AttentionSeverity.Info, Loc.F(Strings.Attention_ManualCostCurrency, attention.ManualCostIds.Count));
case CostAttentionKind.VirtualNotCosted:
return Item(
key, attention.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_VirtualNotCosted, meter),
Strings.Attention_EditCalculation, CalculationLink(attention.MeterId, query));
case CostAttentionKind.BillingConfiguration:
var billing = attention.Totals is { } problem
? Loc.F(Strings.Attention_BillingConfigurationBecause, meter, TotalsReason(problem, problem.OtherMeterId is { } otherId ? names.Meter(otherId) : null))
: Loc.F(Strings.Attention_BillingConfiguration, meter);
return Item(
key, attention.MeterId, AttentionSeverity.Warning, billing,
Strings.Attention_EditMeter, MeterLink(attention.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
case CostAttentionKind.PriceChangeInsideInterval when attention is { FirstMonth: { } first, LastMonth: { } last }:
return Item(
key, attention.MeterId, AttentionSeverity.Warning,
Loc.F(Strings.Attention_PriceChangeInsideInterval, meter, Format.MonthYear(first), Format.MonthYear(last)),
Strings.Attention_OpenTariffs, TariffLinks.Path);
case CostAttentionKind.BillingBasisGap when attention is { FirstMonth: { } first, LastMonth: { } last }:
return Item(
key, attention.MeterId, AttentionSeverity.Warning,
Loc.F(Strings.Attention_BillingBasisGap, meter, Format.MonthYear(first), Format.MonthYear(last)),
Strings.Attention_EditMeter, MeterLink(attention.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
case CostAttentionKind.CategoryPricesNothing:
// A category of calculated views or generation prices nothing (D-39, D-42): name the members and where
// to change the membership, never "no data yet" (A-22).
var members = attention.MeterIds.Count > 0 ? attention.MeterIds : attention.MeterId is { } only ? [only] : [];
return Item(
key + ":" + attention.CategoryId?.ToString(CultureInfo.InvariantCulture), attention.MeterId, AttentionSeverity.Info,
Loc.F(Strings.Attention_CategoryPricesNothing, names.Category(attention.CategoryId), string.Join(", ", members.Select(id => names.Meter(id)))),
Strings.Attention_EditCategories, CategoriesPath);
default:
return Item(key, attention.MeterId, AttentionSeverity.Info, attention.MeterId is null ? attention.Kind.Display() : meter + ": " + attention.Kind.Display());
}
}
/// <summary>
/// A price a figure needed and did not get (D-38): the scope it is missing for, the component and the first month
/// that lacks it, with the tariff deep link (D-52). A missing feed-in price is an optional credit.
/// </summary>
public static AttentionItem MissingPrice(MissingPrice price, AttentionNames names)
{
ArgumentNullException.ThrowIfNull(price);
ArgumentNullException.ThrowIfNull(names);
var scope = price.MeterId is { } meterId
? names.Meter(meterId)
: price.Scope switch
{
TariffScope.Meter => names.Meter(price.ScopeId),
TariffScope.EnergyType => names.EnergyType(price.ScopeId),
_ => Strings.Attention_AllEnergyTypes,
};
var component = price.Component.Display();
var month = Format.MonthYear(price.FirstMonth);
// A unit mismatch says what does not fit (D-37): the currency, a base price's period, or the meter's unit.
var (text, severity, action) = price.Reason switch
{
CostStatus.NotPriced => (Loc.F(Strings.Attention_PriceNotSetUp, scope, component), AttentionSeverity.Warning, Strings.Attention_AddTariff),
CostStatus.UnitMismatch when price.Issue == TariffUnitIssue.CurrencyMismatch =>
(Loc.F(Strings.Attention_PriceCurrencyMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
CostStatus.UnitMismatch when price.Component == TariffComponent.BasePrice =>
(Loc.F(Strings.Attention_BasePriceUnitMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
CostStatus.UnitMismatch => (Loc.F(Strings.Attention_PriceUnitMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
_ when price.IsCredit => (Loc.F(Strings.Attention_CreditMissing, scope, component, month), AttentionSeverity.Info, Strings.Attention_AddTariff),
_ => (Loc.F(Strings.Attention_PriceMissing, scope, component, month), AttentionSeverity.Warning, Strings.Attention_AddTariff),
};
var key = string.Create(
CultureInfo.InvariantCulture,
$"price:{price.Reason}:{price.Scope}:{price.ScopeId}:{price.MeterId}:{price.Component}:{price.FirstMonth:yyyy-MM}");
return new AttentionItem(key, severity, text, action, TariffLinks.For(price));
}
private static AttentionItem RecordedAfterNow(AnalysisProblem problem, string meter, AnalysisQuery? query)
{
if (problem.AfterNow is not { } block)
{
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_RecordedAfterNowPlain, meter));
}
var text = Loc.F(Strings.Attention_RecordedAfterNow, meter, Format.DateRange(block.FirstDay, block.LastDay));
string? link = null;
if (PeriodResolver.IsValidCustomRange(block.FirstDay, block.LastDay))
{
var range = (query ?? AnalysisQuery.Default(AnalysisDefaults.History)).WithCustomRange(block.FirstDay, block.LastDay);
link = MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, range);
}
return Item(problem.Kind, block.MeterId, AttentionSeverity.Info, text, link is null ? null : Strings.Attention_ShowRows, link);
}
private static AttentionItem PossibleOverlap(AnalysisProblem problem, AttentionNames names, string meter, AnalysisQuery? query)
{
if (problem.Hint is not { } hint)
{
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_PossibleOverlapPlain, meter),
Strings.Attention_EditMeter, MeterLink(problem.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
}
var other = names.Meter(hint.OtherMeterId);
var text = hint.Kind switch
{
OverlapHintKind.NotLinkedBelowTotalLoad => Loc.F(Strings.Attention_AssumedBelowTotalLoad, meter, other),
OverlapHintKind.GridImportNotLinkedToTotalLoad => Loc.F(Strings.Attention_GridImportNotLinked, meter, other),
_ => meter + ": " + hint.Kind.Display(),
};
return Item(
problem.Kind, hint.MeterId, AttentionSeverity.Info, text,
Strings.Attention_ManageMeters, AnalysisLinks.EnergyType(hint.EnergyTypeId, AnalysisLinks.EnergyTabMeters, query));
}
/// <summary>
/// Why a calculation is invalid, in words (D-26): the finding's sentence, and in brackets what it is about — the
/// meters it names (the loop as a path), or the units and kinds that do not fit.
/// </summary>
public static string VirtualReason(VirtualProblem problem, AttentionNames names)
{
ArgumentNullException.ThrowIfNull(names);
return VirtualReasonText(problem, names);
}
/// <summary>
/// <see cref="VirtualReason(VirtualProblem, AttentionNames)"/> without the meters the finding names, for a page that
/// lists them itself as links beside the sentence (the meter's Calculation tab): the same words everywhere.
/// </summary>
public static string VirtualReasonWithoutMeters(VirtualProblem problem) => VirtualReasonText(problem, null);
private static string VirtualReasonText(VirtualProblem problem, AttentionNames? names)
{
ArgumentNullException.ThrowIfNull(problem);
var detail = problem.Kind switch
{
VirtualProblemKind.DependencyCycle when names is not null && problem.MeterIds.Count > 0 =>
string.Join(" → ", problem.MeterIds.Select(id => names.Meter(id))),
VirtualProblemKind.UnitMismatch or VirtualProblemKind.ResultUnitMismatch or VirtualProblemKind.IndicatorNeedsUnit
when problem.Values.Count > 0 => string.Join(", ", problem.Values),
VirtualProblemKind.KindMismatch or VirtualProblemKind.ResultKindMismatch or VirtualProblemKind.ResultKindRequired
or VirtualProblemKind.ResultKindUnsupported when problem.Values.Count > 0 => string.Join(", ", problem.Values.Select(KindWord)),
VirtualProblemKind.Syntax or VirtualProblemKind.NoReferences => null,
_ when names is not null && problem.MeterIds.Count > 0 => string.Join(", ", problem.MeterIds.Distinct().Select(id => names.Meter(id))),
_ => null,
};
var sentence = problem.Kind.Display();
return detail is null ? sentence : sentence + " (" + detail + ")";
}
/// <summary>What contradicts itself in the totals configuration (D-22, D-23), with the role it is about.</summary>
public static string TotalsReason(TotalsProblem problem, string? otherMeter = null)
{
ArgumentNullException.ThrowIfNull(problem);
var sentence = problem.Kind.Display();
var detail = problem.Role is { } role ? role.Display() : otherMeter;
return detail is null ? sentence : sentence + " (" + detail + ")";
}
/// <summary>A quantity kind the validator names by its token, in words ("mixed" too); anything else as it is.</summary>
private static string KindWord(string value) => MeterEditing.MeterEditorText.KindWord(value);
/// <summary>The cost category editor.</summary>
public const string CategoriesPath = "/admin/categories";
private static string? CalculationLink(int? meterId, AnalysisQuery? query) => MeterLink(meterId, MeterLinks.TabCalculation, null, query);
private static string? MeterLink(int? meterId, string tab, string? action, AnalysisQuery? query) =>
meterId is { } id ? MeterLinks.Detail(id, tab, action, query) : null;
private static AttentionItem Item(
AnalysisProblemKind kind, int? meterId, AttentionSeverity severity, string text, string? actionText = null, string? href = null) =>
Item("problem:" + kind, meterId, severity, text, actionText, href);
private static AttentionItem Item(
string kind, int? meterId, AttentionSeverity severity, string text, string? actionText = null, string? href = null)
{
var key = string.Create(CultureInfo.InvariantCulture, $"{kind}:{meterId}:{text}");
return new AttentionItem(key, severity, text, href is null ? null : actionText, href);
}
}
+106
View File
@@ -0,0 +1,106 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
namespace MeterVault.App.Analysis;
/// <summary>Which direction of a change is good news for a metric (D-08, brief §4.2).</summary>
public enum ChangePolarity
{
/// <summary>More is worse: consumption, runtime, cost.</summary>
HigherIsWorse,
/// <summary>More is better: generation, export.</summary>
HigherIsBetter,
/// <summary>Neither: a signed net result, an indicator, a tank level, a cost that is a credit.</summary>
Neutral,
}
/// <summary>How a change is coloured: good, bad or neither. Never shown by colour alone — the words and the arrow carry it.</summary>
public enum ChangeTone
{
Neutral,
Good,
Bad,
}
/// <summary>The polarity of a metric's changes (D-08: more consumption is not good, more generation is).</summary>
public static class ChangePolarities
{
/// <summary>The polarity of a quantity kind; net results and indicators are neutral.</summary>
public static ChangePolarity For(QuantityKind kind) => kind switch
{
QuantityKind.Consumption or QuantityKind.Runtime or QuantityKind.Cost => ChangePolarity.HigherIsWorse,
QuantityKind.Generation or QuantityKind.Export => ChangePolarity.HigherIsBetter,
_ => ChangePolarity.Neutral,
};
/// <summary>The polarity of a toolbar metric; net and tank level are neutral.</summary>
public static ChangePolarity For(AnalysisMetric metric) => metric switch
{
AnalysisMetric.Consumption or AnalysisMetric.Runtime or AnalysisMetric.Cost => ChangePolarity.HigherIsWorse,
AnalysisMetric.Generation or AnalysisMetric.Export => ChangePolarity.HigherIsBetter,
_ => ChangePolarity.Neutral,
};
/// <summary>
/// A cost rising is worse — unless either side is a credit (a negative cost, a feed-in larger than the charges):
/// then "more" and "less" have no settled meaning and the change is neutral.
/// </summary>
public static ChangePolarity ForCost(double? current, double? previous) =>
current < 0 || previous < 0 ? ChangePolarity.Neutral : ChangePolarity.HigherIsWorse;
}
/// <summary>
/// A change in words (D-08): the absolute difference always, the percentage where it applies ("percentage not
/// applicable" otherwise), and the direction as a word — so a change is readable without its colour.
/// </summary>
public static class ChangeDisplay
{
/// <summary>Good, bad or neutral for <paramref name="polarity"/>; neutral when unknown or unchanged.</summary>
public static ChangeTone Tone(Change change, ChangePolarity polarity)
{
ArgumentNullException.ThrowIfNull(change);
if (!change.IsAvailable || change.Direction == 0 || polarity == ChangePolarity.Neutral)
{
return ChangeTone.Neutral;
}
return (change.Direction > 0) == (polarity == ChangePolarity.HigherIsBetter) ? ChangeTone.Good : ChangeTone.Bad;
}
/// <summary>
/// "12 kWh more (+4.5 %)", "3.50 € less (-2.0 %)", "12 kWh more (percentage not applicable)", "No change", or
/// "No comparison" when either value is unknown.
/// </summary>
/// <param name="change">The change.</param>
/// <param name="formatMagnitude">Formats the size of the difference (a quantity with its unit, money).</param>
public static string Words(Change change, Func<double, string> formatMagnitude)
{
ArgumentNullException.ThrowIfNull(change);
ArgumentNullException.ThrowIfNull(formatMagnitude);
if (change.Absolute is not { } difference)
{
return Strings.Change_Unavailable;
}
if (change.Direction == 0)
{
return Strings.Change_None;
}
var magnitude = formatMagnitude(Math.Abs(difference));
var words = Loc.F(change.Direction > 0 ? Strings.Change_More : Strings.Change_Less, magnitude);
return words + " (" + Format.ChangePercent(change) + ")";
}
/// <summary>The CSS class colouring a tone with the theme's palette (<c>app.css</c>).</summary>
public static string CssClass(ChangeTone tone) => tone switch
{
ChangeTone.Good => "mv-change-good",
ChangeTone.Bad => "mv-change-bad",
_ => "mv-change-neutral",
};
}
+56
View File
@@ -0,0 +1,56 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.App.Analysis;
/// <summary>
/// How every page states a cost change (D-07, brief §10 Phase 4 exit): one rule — <see cref="OverviewComparison.Between"/>,
/// the totals when both periods are complete, else the paired buckets both sides have complete, else not comparable —
/// and one wording, so the Overview, an energy type, the Analysis page and a meter never disagree about the same scope and
/// period.
/// </summary>
public static class CostChanges
{
/// <summary>The change for a card: null without a comparison; unavailable ("No comparison") when not comparable.</summary>
public static Change? ForCard(CostChange change)
{
ArgumentNullException.ThrowIfNull(change);
return change.Basis == CostChangeBasis.NoComparison ? null : change.Change;
}
/// <summary>The change for a table's total row; null unless one is stated.</summary>
public static Change? ForTotalRow(CostChange change)
{
ArgumentNullException.ThrowIfNull(change);
return change.Change.IsAvailable ? change.Change : null;
}
/// <summary>Whether a rise is good news: neutral when either amount is a credit.</summary>
public static ChangePolarity Polarity(CostChange change)
{
ArgumentNullException.ThrowIfNull(change);
return ChangePolarities.ForCost(change.Current, change.Previous);
}
/// <summary>
/// The caption of a change: what it is compared with, and — when only part of the period could be matched — that it
/// is ("Same period last year · over the part both periods cover").
/// </summary>
public static string? Caption(AnalysisQuery query, CostChange change)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(change);
if (change.Basis == CostChangeBasis.NoComparison)
{
return null;
}
var compared = query.Comparison.Display();
return change.IsPartial ? compared + " · " + Strings.Overview_MatchedOnly : compared;
}
}
+19
View File
@@ -0,0 +1,19 @@
using MeterVault.Core.Analysis;
using MeterVault.Infrastructure.Costing;
namespace MeterVault.App.Analysis;
/// <summary>
/// How a cost figure is compared (D-06), from <see cref="AnalysisQuery.ToCostComparison"/>: the comparison as resolved,
/// the cost request that prices it in the paired buckets, and the pairs themselves.
/// </summary>
/// <param name="Resolution">The comparison period, or why there is none (a code the UI words).</param>
/// <param name="Request">The request to price the comparison with; null when the comparison does not apply.</param>
/// <param name="Pairs">Each current bucket with its image, by index (A-10); empty when the comparison does not apply.</param>
public sealed record CostComparisonRequest(
ComparisonResolution Resolution,
CostAnalysisRequest? Request,
IReadOnlyList<BucketPair> Pairs)
{
public bool IsApplicable => Request is not null;
}
+131
View File
@@ -0,0 +1,131 @@
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
namespace MeterVault.App.Analysis;
/// <summary>
/// How one figure reads beside its number (brief §4.3, D-14): whether a number can be shown at all, whether it is
/// qualified — partial, estimated, an opening balance, only partly priced — and its status, detail and provenance in the
/// reader's words. Charts mark a qualified figure, tables and tooltips word it, so its meaning never rests on colour.
/// </summary>
/// <param name="IsKnown">A number can be shown (a partial total counts; a missing or invalid bucket does not).</param>
/// <param name="IsComplete">
/// The figure is complete: an available bucket, or a fully priced cost over available quantities. Only complete figures
/// are compared bucket by bucket (D-07); estimated provenance does not make a figure incomplete.
/// </param>
/// <param name="IsQualified">The figure is not a plain complete measured value: incomplete, estimated or an opening balance.</param>
/// <param name="Status">The status in words ("Complete", "Partial", "Not priced (no tariff)").</param>
/// <param name="Detail">Why, in words, or null.</param>
/// <param name="Provenance">Where the value comes from, in words ("Measured, Estimated"); empty when there is none.</param>
public sealed record FigureStatus(bool IsKnown, bool IsComplete, bool IsQualified, string Status, string? Detail, string Provenance)
{
/// <summary>Status and provenance in one line: "Partial · Measured".</summary>
public string Summary => string.IsNullOrEmpty(Provenance) ? Status : Status + " · " + Provenance;
/// <summary>The summary with its detail: "Partial · Measured — Data covers only part of this period".</summary>
public string Full => Detail is null ? Summary : Summary + " — " + Detail;
}
/// <summary>Words a <see cref="BucketValue"/> or a <see cref="CostAmount"/> (<see cref="FigureStatus"/>).</summary>
public static class FigureText
{
/// <summary>Provenance that qualifies a value even when its bucket is complete.</summary>
private const Provenance QualifyingProvenance = Provenance.Estimated | Provenance.OpeningBalance;
/// <summary>
/// A quantity bucket: its status and issue (<see cref="DisplayNames"/>), the issue's detail, and — for a value derived
/// from a dependency — the meter that caused it (the last id of <see cref="BucketValue.DependencyPath"/>).
/// </summary>
/// <param name="value">The value.</param>
/// <param name="meterName">Names a meter id for the dependency detail; "#id" without it.</param>
public static FigureStatus Of(BucketValue value, Func<int, string?>? meterName = null)
{
ArgumentNullException.ThrowIfNull(value);
var known = value.Value is { } number && double.IsFinite(number);
var complete = value.Status == BucketStatus.Available;
var qualified = !complete || (value.Provenance & QualifyingProvenance) != 0;
return new FigureStatus(known, complete, qualified, value.Status.Display(), DetailOf(value, meterName), value.Provenance.Display());
}
/// <summary>
/// A cost figure: its price coverage (<see cref="CostAmount.Status"/>), and as detail the availability of the
/// quantities behind it, components left unpriced and unchecked tariff units.
/// </summary>
public static FigureStatus Of(CostAmount amount)
{
ArgumentNullException.ThrowIfNull(amount);
// Nothing to bill and nothing missing (no line, no charge, no manual cost in the bucket): the engine keeps the
// figure unknown, so it reads "No data" — never "Priced" beside "—", and never complete (brief §4.3).
if (IsNothingBooked(amount))
{
return new FigureStatus(false, false, true, BucketStatus.Missing.Display(), null, string.Empty);
}
var known = amount.Cost is { } cost && double.IsFinite(cost);
var complete = amount.Status == CostStatus.Priced && amount.Availability == BucketStatus.Available;
var qualified = !complete || amount.Unverified;
// Prices cover the bucket but its quantity is unknown (no data, pending, unresolved): the cost is unknown
// because of the quantity, so that is its status — "Priced" beside "—" would read as a priced figure.
var unknownQuantity = !known && amount.Status == CostStatus.Priced && amount.Availability != BucketStatus.Available;
var details = new List<string>(3);
if (amount.Availability != BucketStatus.Available && !unknownQuantity)
{
details.Add(Loc.F(Strings.Figure_QuantityStatus, amount.Availability.Display()));
}
if (amount.IncludesNotPriced && amount.Status is CostStatus.Priced or CostStatus.Partial)
{
details.Add(Strings.Figure_SomeNotPriced);
}
if (amount.Unverified)
{
details.Add(Strings.Figure_UnverifiedUnit);
}
var status = unknownQuantity ? amount.Availability.Display() : amount.Status.Display();
return new FigureStatus(
known, complete, qualified, status, details.Count > 0 ? string.Join("; ", details) : null, string.Empty);
}
/// <summary>
/// True for a cost figure with nothing booked in it: no value, nothing unpriced, no quantity unavailable — the empty
/// figure of a bucket with no line, charge or manual cost (<see cref="CostAmount.Empty"/>). It is unknown, not a
/// priced zero; tables, charts and the CSV export word it as "No data" alike.
/// </summary>
public static bool IsNothingBooked(CostAmount amount)
{
ArgumentNullException.ThrowIfNull(amount);
return amount.Cost is null && amount.Status == CostStatus.Priced && amount.Availability == BucketStatus.Available
&& amount.MissingPrices.Count == 0;
}
private static string? DetailOf(BucketValue value, Func<int, string?>? meterName)
{
if (value.Issue == ValueIssue.None)
{
return null;
}
var extras = new List<string>(2);
if (!string.IsNullOrWhiteSpace(value.IssueDetail))
{
extras.Add(value.IssueDetail.Trim());
}
if (value.DependencyPath is { Count: > 1 } path)
{
var culprit = path[^1];
extras.Add(meterName?.Invoke(culprit) is { Length: > 0 } name ? name : "#" + culprit.ToString(System.Globalization.CultureInfo.CurrentCulture));
}
var issue = value.Issue.Display();
return extras.Count == 0 ? issue : issue + " (" + string.Join(", ", extras) + ")";
}
}
+112
View File
@@ -0,0 +1,112 @@
using System.Globalization;
using System.Text;
namespace MeterVault.App.Analysis;
/// <summary>A piece of a virtual meter's formula: plain text, or a meter reference (<c>m12</c>) with its id.</summary>
public sealed record FormulaSegment(string Text, int? MeterId)
{
public bool IsMeter => MeterId is not null;
}
/// <summary>
/// A virtual meter's formula for display (brief §5.1): split into text and meter references so each <c>m&lt;id&gt;</c>
/// token can stand beside its meter's friendly name. The scan mirrors the formula lexer: an identifier is a letter or
/// underscore followed by letters, digits or underscores, a reference is exactly <c>m</c> and digits, and a number run
/// (digits and dots) is skipped whole, so the "3" of "1.3" is never read as part of a name.
/// </summary>
public static class FormulaText
{
/// <summary>Splits an expression into segments; empty for null or blank.</summary>
public static IReadOnlyList<FormulaSegment> Split(string? expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
return [];
}
var segments = new List<FormulaSegment>();
var text = new StringBuilder();
var pos = 0;
while (pos < expression.Length)
{
var c = expression[pos];
if (char.IsLetter(c) || c == '_')
{
var start = pos;
while (pos < expression.Length && (char.IsLetterOrDigit(expression[pos]) || expression[pos] == '_'))
{
pos++;
}
var token = expression[start..pos];
if (TryParseReference(token, out var id))
{
Flush(segments, text);
segments.Add(new FormulaSegment(token, id));
}
else
{
text.Append(token);
}
}
else if (char.IsAsciiDigit(c) || c == '.')
{
var start = pos;
while (pos < expression.Length && (char.IsAsciiDigit(expression[pos]) || expression[pos] == '.'))
{
pos++;
}
text.Append(expression, start, pos - start);
}
else
{
text.Append(c);
pos++;
}
}
Flush(segments, text);
return segments;
}
/// <summary>
/// The expression with each reference followed by its meter's name: <c>m5 (Solar 1) + m6 (Solar 2)</c>. A reference
/// nobody named stays as it is.
/// </summary>
public static string Annotate(string? expression, Func<int, string?> name)
{
ArgumentNullException.ThrowIfNull(name);
var builder = new StringBuilder();
foreach (var segment in Split(expression))
{
builder.Append(segment.Text);
if (segment.MeterId is { } id && name(id) is { Length: > 0 } meter)
{
builder.Append(" (").Append(meter).Append(')');
}
}
return builder.ToString();
}
private static bool TryParseReference(string token, out int id)
{
id = 0;
return token.Length >= 2
&& token[0] == 'm'
&& token.AsSpan(1).IndexOfAnyExceptInRange('0', '9') < 0
&& int.TryParse(token.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out id);
}
private static void Flush(List<FormulaSegment> segments, StringBuilder text)
{
if (text.Length > 0)
{
segments.Add(new FormulaSegment(text.ToString(), null));
text.Clear();
}
}
}
+218
View File
@@ -0,0 +1,218 @@
namespace MeterVault.App.Analysis;
/// <summary>One requested load: its generation and the token that cancels it when a newer load is requested.</summary>
public readonly record struct LoadTicket(long Generation, CancellationToken Token);
/// <summary>
/// Makes sure only the latest requested load is committed (brief §8, A13): <see cref="Next"/> cancels the load before it
/// and hands out a ticket; a load commits its result only while <see cref="IsCurrent"/> holds for its ticket. A delayed
/// first request can then never overwrite the scope or range the user selected after it — the old
/// <c>if (_loading) return;</c> guard dropped the newer request instead.
/// </summary>
/// <remarks>
/// <para>The page pattern (one sequencer per independently loading panel):</para>
/// <code>
/// private readonly LoadSequencer _loads = new();
/// private readonly LoadState&lt;AnalysisResult&gt; _result = new();
///
/// protected override async Task OnParametersSetAsync()
/// {
/// var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
/// if (query == _query) return; // an action drop or a tab change is not a new analysis (D-46)
/// _query = query;
/// await _loads.RunAsync(_result, async token =>
/// {
/// var period = await Periods.ResolveAsync(query, Clock.Now, token);
/// return await Reader.ReadAsync(query.ToAnalysisRequest(period)!, token);
/// }, Logger);
/// }
///
/// public void Dispose() => _loads.Dispose();
/// </code>
/// <para>
/// Render from <see cref="LoadState{T}"/>: <see cref="LoadState{T}.IsInitialLoad"/> → skeleton/progress;
/// <see cref="LoadState{T}.Value"/> with <see cref="LoadState{T}.IsRefreshing"/> → the previous result, dimmed, with a
/// thin progress bar (stale but visible); <see cref="LoadState{T}.Error"/> → a panel-level error with Retry (and, when a
/// value is kept, the note that it is from before); otherwise the value. Everything a panel shows — title, chart, table —
/// comes from the one committed value, so a new type's title never sits above the previous type's chart.
/// </para>
/// <para>
/// Initial loads stay in <c>OnInitialized</c>/<c>OnParametersSet</c> (D-46): the render tests read prerendered data.
/// </para>
/// </remarks>
public sealed class LoadSequencer : IDisposable
{
private readonly object _gate = new();
private CancellationTokenSource? _current;
private long _generation;
private bool _disposed;
/// <summary>The generation of the latest ticket; 0 before the first.</summary>
public long Generation
{
get
{
lock (_gate)
{
return _generation;
}
}
}
/// <summary>Starts a new load: cancels the previous one and returns the new ticket.</summary>
/// <exception cref="ObjectDisposedException">The sequencer (its component) is disposed.</exception>
public LoadTicket Next()
{
CancellationTokenSource? previous;
LoadTicket ticket;
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
previous = _current;
_current = new CancellationTokenSource();
_generation++;
ticket = new LoadTicket(_generation, _current.Token);
}
// Cancelled before it is disposed, so the token the superseded load holds stays cancelled (and usable).
previous?.Cancel();
previous?.Dispose();
return ticket;
}
/// <summary>True while <paramref name="ticket"/> is the latest load and has not been cancelled: only then may it commit.</summary>
public bool IsCurrent(LoadTicket ticket)
{
lock (_gate)
{
return !_disposed && ticket.Generation == _generation && !ticket.Token.IsCancellationRequested;
}
}
/// <summary>
/// Runs one load through <paramref name="state"/>: marks it loading, awaits <paramref name="load"/> with the ticket's
/// token, and commits the value or the error — only if no newer load was requested meanwhile. A superseded or
/// cancelled load changes nothing. Errors are logged and kept in the state for a panel-level Retry; they never
/// escape to the circuit.
/// </summary>
/// <returns>True when this load committed (a value or an error), false when it was superseded.</returns>
public async Task<bool> RunAsync<T>(LoadState<T> state, Func<CancellationToken, Task<T>> load, ILogger? logger = null)
where T : class
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(load);
var ticket = Next();
state.Begin(ticket.Generation);
try
{
var value = await load(ticket.Token);
if (!IsCurrent(ticket))
{
return false;
}
state.Commit(value);
return true;
}
catch (OperationCanceledException) when (ticket.Token.IsCancellationRequested)
{
return false;
}
// A panel shows its own error with Retry: nothing a reader throws may end the circuit.
catch (Exception ex)
{
if (!IsCurrent(ticket))
{
return false;
}
logger?.LogError(ex, "Loading {Panel} failed", typeof(T).Name);
state.Fail(ex);
return true;
}
}
/// <summary>Cancels the load in flight; no ticket is current afterwards.</summary>
public void Dispose()
{
CancellationTokenSource? current;
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
current = _current;
_current = null;
}
current?.Cancel();
current?.Dispose();
}
}
/// <summary>
/// What a panel shows while it loads (brief §8): the last committed value, whether a load is running, and the error of
/// the last load. The value is kept through a refresh and a failure, so a panel stays readable ("stale but visible")
/// instead of blanking.
/// </summary>
public sealed class LoadState<T>
where T : class
{
/// <summary>The last committed value; null before the first load finished.</summary>
public T? Value { get; private set; }
/// <summary>True while a load is running.</summary>
public bool IsLoading { get; private set; }
/// <summary>The error of the last committed load; null after a success.</summary>
public Exception? Error { get; private set; }
/// <summary>The generation of the running or last committed load.</summary>
public long Generation { get; private set; }
/// <summary>Nothing to show yet: the first load is running.</summary>
public bool IsInitialLoad => IsLoading && Value is null;
/// <summary>A value is shown while a newer one loads.</summary>
public bool IsRefreshing => IsLoading && Value is not null;
/// <summary>The value shown is not the answer to the current request: a newer load is running, or it failed.</summary>
public bool IsStale => Value is not null && (IsLoading || Error is not null);
/// <summary>A load is starting.</summary>
public void Begin(long generation)
{
Generation = generation;
IsLoading = true;
}
/// <summary>The load finished with <paramref name="value"/>.</summary>
public void Commit(T value)
{
Value = value;
Error = null;
IsLoading = false;
}
/// <summary>The load failed; the previous value, if any, stays visible.</summary>
public void Fail(Exception error)
{
ArgumentNullException.ThrowIfNull(error);
Error = error;
IsLoading = false;
}
/// <summary>Forgets the value (e.g. when the page moved to a different entity whose old data must not show).</summary>
public void Clear()
{
Value = null;
Error = null;
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Globalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
namespace MeterVault.App.Analysis;
/// <summary>What an analysis URL is about (D-47 <c>scope=portfolio|type|category|meter|meters</c>).</summary>
public enum QueryScopeKind
{
/// <summary>Everything: every energy type's totals and the whole bill.</summary>
Portfolio,
/// <summary>One energy type (<c>scope=type&amp;id=</c>).</summary>
EnergyType,
/// <summary>One cost category (<c>scope=category&amp;id=</c>); analysed by cost.</summary>
Category,
/// <summary>One meter, physical or virtual (<c>scope=meter&amp;id=</c>).</summary>
Meter,
/// <summary>An explicit selection of meters side by side (<c>scope=meters&amp;ids=3,5</c>, at most <see cref="AnalysisLimits.MaxSeries"/>).</summary>
Meters,
}
/// <summary>
/// The scope of an <see cref="AnalysisQuery"/>: a kind and the id(s) it names. Immutable, compared by value, and mapped
/// in one place onto the quantity reader's <see cref="AnalysisScope"/> and the cost reader's <see cref="CostScope"/>.
/// </summary>
public sealed class QueryScope : IEquatable<QueryScope>
{
private static readonly (QueryScopeKind Value, string Token)[] Tokens =
[
(QueryScopeKind.Portfolio, "portfolio"),
(QueryScopeKind.EnergyType, "type"),
(QueryScopeKind.Category, "category"),
(QueryScopeKind.Meter, "meter"),
(QueryScopeKind.Meters, "meters"),
];
private QueryScope(QueryScopeKind kind, int? id, IReadOnlyList<int> meterIds)
{
Kind = kind;
Id = id;
MeterIds = meterIds;
}
/// <summary>Every energy type and the whole bill.</summary>
public static QueryScope Portfolio { get; } = new(QueryScopeKind.Portfolio, null, []);
public QueryScopeKind Kind { get; }
/// <summary>The energy type, category or meter id; null for the portfolio and a meter selection.</summary>
public int? Id { get; }
/// <summary>The meters of a selection (distinct, in the order given); the one meter of a meter scope; empty otherwise.</summary>
public IReadOnlyList<int> MeterIds { get; }
/// <summary>The URL token of <see cref="Kind"/>.</summary>
public string Token => TokenOf(Kind);
public static QueryScope ForEnergyType(int energyTypeId) => new(QueryScopeKind.EnergyType, Positive(energyTypeId, nameof(energyTypeId)), []);
public static QueryScope ForCategory(int categoryId) => new(QueryScopeKind.Category, Positive(categoryId, nameof(categoryId)), []);
public static QueryScope ForMeter(int meterId)
{
Positive(meterId, nameof(meterId));
return new QueryScope(QueryScopeKind.Meter, meterId, [meterId]);
}
/// <summary>
/// An explicit selection. The ids are made distinct (first occurrence wins). More than
/// <see cref="AnalysisLimits.MaxSeries"/> are kept as given; the reader refuses them (<see cref="AnalysisRefusal.TooManySeries"/>),
/// and <see cref="AnalysisQuery.Parse(string, AnalysisDefaults)"/> caps them with a notice.
/// </summary>
/// <exception cref="ArgumentException">No id, or an id that is not positive.</exception>
public static QueryScope ForMeters(IEnumerable<int> meterIds)
{
ArgumentNullException.ThrowIfNull(meterIds);
List<int> ids = [.. meterIds.Distinct()];
if (ids.Count == 0)
{
throw new ArgumentException("A meter selection needs at least one meter.", nameof(meterIds));
}
if (ids.Any(id => id <= 0))
{
throw new ArgumentException("Meter ids are positive.", nameof(meterIds));
}
return new QueryScope(QueryScopeKind.Meters, null, ids);
}
/// <summary>The URL token of a scope kind.</summary>
public static string TokenOf(QueryScopeKind kind)
{
foreach (var (value, token) in Tokens)
{
if (value == kind)
{
return token;
}
}
throw new ArgumentOutOfRangeException(nameof(kind), kind, "No URL token for this scope.");
}
/// <summary>Parses a scope token (<c>portfolio</c>, <c>type</c>, …), ignoring case and surrounding blanks.</summary>
public static bool TryParseKind(string? token, out QueryScopeKind kind)
{
kind = default;
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var text = token.Trim();
foreach (var (value, name) in Tokens)
{
if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase))
{
kind = value;
return true;
}
}
return false;
}
/// <summary>
/// The quantity reader's scope: the portfolio, an energy type, a meter or a selection. Null for a category, which
/// the quantity reader does not know — a category is analysed by cost (brief §7.4).
/// </summary>
public AnalysisScope? ToAnalysisScope() => Kind switch
{
QueryScopeKind.Portfolio => AnalysisScope.Portfolio,
QueryScopeKind.EnergyType => AnalysisScope.ForEnergyType(Id!.Value),
QueryScopeKind.Meter => AnalysisScope.ForMeter(Id!.Value),
QueryScopeKind.Meters => AnalysisScope.ForMeters(MeterIds),
_ => null,
};
/// <summary>
/// The cost reader's scopes: one for the portfolio, a type, a meter or a category, and one per meter for a selection
/// (each meter is priced by its own rule, never as a sum, D-39).
/// </summary>
public IReadOnlyList<CostScope> ToCostScopes() => Kind switch
{
QueryScopeKind.Portfolio => [CostScope.Portfolio],
QueryScopeKind.EnergyType => [CostScope.ForEnergyType(Id!.Value)],
QueryScopeKind.Category => [CostScope.ForCategory(Id!.Value)],
QueryScopeKind.Meter => [CostScope.ForMeter(Id!.Value)],
_ => [.. MeterIds.Select(CostScope.ForMeter)],
};
public bool Equals(QueryScope? other) =>
other is not null && Kind == other.Kind && Id == other.Id && MeterIds.SequenceEqual(other.MeterIds);
public override bool Equals(object? obj) => Equals(obj as QueryScope);
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(Kind);
hash.Add(Id);
foreach (var id in MeterIds)
{
hash.Add(id);
}
return hash.ToHashCode();
}
/// <summary><c>portfolio</c>, <c>type:3</c>, <c>meter:12</c>, <c>meters:3,5</c> — for logs and keys.</summary>
public override string ToString() => Kind switch
{
QueryScopeKind.Portfolio => Token,
QueryScopeKind.Meters => Token + ":" + string.Join(',', MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture))),
_ => Token + ":" + Id!.Value.ToString(CultureInfo.InvariantCulture),
};
private static int Positive(int id, string paramName) =>
id > 0 ? id : throw new ArgumentOutOfRangeException(paramName, id, "Ids are positive.");
}