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
+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!) : [];
}
}