using MeterVault.App.Analysis; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Quantities; using MeterVault.Infrastructure.Analysis; namespace MeterVault.App.AnalysisPage; /// Why the Analysis page cannot show what its address asks for, and explains instead (brief §7.4). public enum AnalysisPageRefusal { None, /// The energy type, category or meter does not exist (any more). UnknownScope, /// More meters than can be compared side by side (); never cut silently. TooManyMeters, /// A category asked for a quantity while its meters measure different kinds or units. CategoryMixed, /// A category asked for a quantity has no meters (its costs are manual costs only). CategoryWithoutMeters, /// A category asked for a quantity has more meters than can be charted side by side. CategoryTooManyMeters, } /// What the page did differently from the address, and says so. public enum AnalysisPageNoticeKind { /// The metric does not apply to the selection; its natural metric is shown instead. MetricNotAvailable, /// Some selected meters do not exist and were left out. UnknownMetersLeftOut, } /// A page notice with the metric it is about (for ). public sealed record AnalysisPageNotice(AnalysisPageNoticeKind Kind, AnalysisMetric? Requested = null, AnalysisMetric? Shown = null); /// Meters of one kind and unit, e.g. the consumption meters of a mixed category, in kWh. public sealed record AnalysisMeterGroup(QuantityKind Kind, string Unit, IReadOnlyList MeterIds) { public AnalysisMetric? Metric => AnalysisMetrics.MetricOf(Kind); } /// /// The Analysis page's reading of its address against what exists (brief §7.4, D-47): the scope and its name, the metrics /// the scope supports and the one shown, the meters shown as series, and — when the address asks for something that /// cannot be shown as one quantity — the reason in . Pure: the page, the CSV link and the tests read /// the same answer. /// /// /// /// Metrics. The portfolio and an energy type offer the quantity metrics of their per-type measures (D-22) and the /// cost; a meter its own quantity and — when it can be costed — its cost; a comparison the metrics of its meters and the /// cost. A cost category is analysed by cost, and by a quantity only when all its meters measure one kind in one unit. /// A metric the scope does not support falls back to the scope's natural one with a notice (D-02), except a category /// quantity, which is explained, never silently turned into a cost. /// /// /// Series. A comparison shows the meters that measure the chosen metric (a meter measures what it measures) and /// names the others; its cost shows the meters that can have one. A category's quantity is its meters side by side — /// each from the shared reader, never added up, because members may overlap (D-22). /// /// public sealed record AnalysisSelection { private AnalysisSelection(QueryScope scope, AnalysisMetric? metric) { Scope = scope; Metric = metric; } /// The scope shown (unknown meters of a comparison left out). public QueryScope Scope { get; private init; } /// The metric shown; null for a meter's own quantity when it has no metric (an indicator). public AnalysisMetric? Metric { get; private init; } /// The metrics the scope supports, in the order the selector lists them. public IReadOnlyList Metrics { get; private init; } = []; /// What the scope shows without a metric key. public AnalysisMetric? NaturalMetric { get; private init; } /// The meters shown as series (meter, comparison, category quantity); empty for measure and whole-scope cost views. public IReadOnlyList SeriesMeterIds { get; private init; } = []; /// Selected meters not shown for this metric (another kind, or no cost of their own). public IReadOnlyList HiddenMeterIds { get; private init; } = []; /// For : the category's meters by kind and unit. public IReadOnlyList Groups { get; private init; } = []; public AnalysisPageRefusal Refusal { get; private init; } public IReadOnlyList Notices { get; private init; } = []; /// The scope's name (a type, category or meter — user data); null for the portfolio and a comparison. public string? ScopeName { get; private init; } /// The energy type the scope belongs to (a type, or a meter's type). public int? EnergyTypeId { get; private init; } /// True when the metric shown is the cost. public bool IsCost => Metric == AnalysisMetric.Cost; /// True when the values shown are meters' own series (a meter, a comparison, a category's meters). public bool ShowsMeters => !IsCost && Scope.Kind is QueryScopeKind.Meter or QueryScopeKind.Meters or QueryScopeKind.Category; /// /// The query the readers are asked with: the metric shown written out, and for a view of meters their explicit /// selection — so resolving all (D-19), reading and the CSV export all see the same scope. /// public AnalysisQuery ReadQuery(AnalysisQuery query) { ArgumentNullException.ThrowIfNull(query); var read = query.WithMetric(Metric); return ShowsMeters && Scope.Kind != QueryScopeKind.Meter && SeriesMeterIds.Count > 0 ? read.WithScope(QueryScope.ForMeters(SeriesMeterIds)) : read.WithScope(Scope); } /// /// as the page shows it: the scope shown and the metric shown, with no metric key /// when it is the scope's natural one — the state the selectors and the drill-downs build on. /// public AnalysisQuery Shown(AnalysisQuery query) { ArgumentNullException.ThrowIfNull(query); return query.WithScope(Scope).WithMetric(Metric == NaturalMetric ? null : Metric); } /// Reads against . public static AnalysisSelection Resolve(AnalysisQuery query, AnalysisPageOptions options) { ArgumentNullException.ThrowIfNull(query); ArgumentNullException.ThrowIfNull(options); return query.Scope.Kind switch { QueryScopeKind.EnergyType => ForType(query, options), QueryScopeKind.Category => ForCategory(query, options), QueryScopeKind.Meter => ForMeter(query, options), QueryScopeKind.Meters => ForMeters(query, options), _ => ForPortfolio(query, options), }; } /// The metric a scope's natural choice is: consumption when offered, else the first quantity, else the cost. private static AnalysisMetric NaturalOf(IReadOnlyList quantities) => quantities.Contains(AnalysisMetric.Consumption) ? AnalysisMetric.Consumption : quantities.Count > 0 ? quantities[0] : AnalysisMetric.Cost; private static AnalysisSelection ForPortfolio(AnalysisQuery query, AnalysisPageOptions options) { var quantities = options.Types.SelectMany(t => t.QuantityMetrics).ToHashSet(); List metrics = [AnalysisMetric.Cost, .. AnalysisPageOptions.QuantityOrder.Where(quantities.Contains)]; return WithMetric(new AnalysisSelection(QueryScope.Portfolio, null) { Metrics = metrics, NaturalMetric = AnalysisMetric.Cost }, query.Metric); } private static AnalysisSelection ForType(AnalysisQuery query, AnalysisPageOptions options) { if (options.Type(query.Scope.Id) is not { } type) { return Refused(query.Scope, AnalysisPageRefusal.UnknownScope); } List metrics = [.. type.QuantityMetrics, AnalysisMetric.Cost]; var selection = new AnalysisSelection(query.Scope, null) { Metrics = metrics, NaturalMetric = NaturalOf(type.QuantityMetrics), ScopeName = type.Name, EnergyTypeId = type.Id, }; return WithMetric(selection, query.Metric); } private static AnalysisSelection ForCategory(AnalysisQuery query, AnalysisPageOptions options) { if (options.Category(query.Scope.Id) is not { } category) { return Refused(query.Scope, AnalysisPageRefusal.UnknownScope); } var members = category.MeterIds.Select(id => options.Meter(id)).OfType().ToList(); var groups = GroupsOf(members); // A quantity only when every meter measures one kind in one unit (brief §7.4). var single = groups.Count == 1 && groups[0].Metric is { } only ? only : (AnalysisMetric?)null; List metrics = single is { } metric ? [AnalysisMetric.Cost, metric] : [AnalysisMetric.Cost]; var selection = new AnalysisSelection(query.Scope, AnalysisMetric.Cost) { Metrics = metrics, NaturalMetric = AnalysisMetric.Cost, ScopeName = category.Name, Groups = groups, }; if (query.Metric is not { } requested || requested == AnalysisMetric.Cost) { return selection; } if (!requested.IsQuantity() || (single is { } supported && supported != requested)) { // A metric its meters do not measure (or the tank balance): the cost, with a notice. return selection with { Notices = [new AnalysisPageNotice(AnalysisPageNoticeKind.MetricNotAvailable, requested, AnalysisMetric.Cost)] }; } // The quantity asked for cannot be one: explained, never silently shown as the cost. var refusal = members.Count == 0 ? AnalysisPageRefusal.CategoryWithoutMeters : single is null ? AnalysisPageRefusal.CategoryMixed : members.Count > AnalysisLimits.MaxSeries ? AnalysisPageRefusal.CategoryTooManyMeters : AnalysisPageRefusal.None; return selection with { Metric = requested, SeriesMeterIds = refusal == AnalysisPageRefusal.None ? [.. members.Select(m => m.Id)] : [], Refusal = refusal, }; } private static AnalysisSelection ForMeter(AnalysisQuery query, AnalysisPageOptions options) { if (options.Meter(query.Scope.Id) is not { } meter) { return Refused(query.Scope, AnalysisPageRefusal.UnknownScope); } List metrics = []; if (meter.Metric is { } own) { metrics.Add(own); } if (meter.IsCostable) { metrics.Add(AnalysisMetric.Cost); } var selection = new AnalysisSelection(query.Scope, null) { Metrics = metrics, NaturalMetric = meter.Metric, SeriesMeterIds = [meter.Id], ScopeName = meter.Name, EnergyTypeId = meter.EnergyTypeId, }; return WithMetric(selection, query.Metric); } private static AnalysisSelection ForMeters(AnalysisQuery query, AnalysisPageOptions options) { var requested = query.Scope.MeterIds; if (requested.Count > AnalysisLimits.MaxSeries) { return Refused(query.Scope, AnalysisPageRefusal.TooManyMeters); } var meters = requested.Select(id => options.Meter(id)).OfType().ToList(); if (meters.Count == 0) { return Refused(query.Scope, AnalysisPageRefusal.UnknownScope); } List notices = meters.Count < requested.Count ? [new AnalysisPageNotice(AnalysisPageNoticeKind.UnknownMetersLeftOut)] : []; var scope = QueryScope.ForMeters(meters.Select(m => m.Id)); List metrics = [ .. meters.Select(m => m.Metric).OfType().Distinct().OrderBy(IndexOf), ]; if (meters.Any(m => m.IsCostable)) { metrics.Add(AnalysisMetric.Cost); } var selection = WithMetric( new AnalysisSelection(scope, null) { Metrics = metrics, NaturalMetric = meters[0].Metric, Notices = notices }, query.Metric); // A meter measures what it measures: the metric picks which of the selected meters are compared. var shown = selection.IsCost ? meters.Where(m => m.IsCostable).ToList() : meters.Where(m => m.Metric == selection.Metric).ToList(); return selection with { SeriesMeterIds = [.. shown.Select(m => m.Id)], HiddenMeterIds = [.. meters.Except(shown).Select(m => m.Id)], }; } /// The metric shown for a requested one: the request when supported, else the natural metric with a notice. private static AnalysisSelection WithMetric(AnalysisSelection selection, AnalysisMetric? requested) { if (requested is not { } metric || metric == selection.NaturalMetric) { return selection with { Metric = selection.NaturalMetric }; } if (selection.Metrics.Contains(metric)) { return selection with { Metric = metric }; } return selection with { Metric = selection.NaturalMetric, Notices = [.. selection.Notices, new AnalysisPageNotice(AnalysisPageNoticeKind.MetricNotAvailable, metric, selection.NaturalMetric)], }; } private static AnalysisSelection Refused(QueryScope scope, AnalysisPageRefusal refusal) => new(scope, null) { Refusal = refusal }; private static List GroupsOf(IEnumerable meters) => [ .. meters .GroupBy(m => (m.Kind, Unit: Units.Normalize(m.Unit))) .Select(g => new AnalysisMeterGroup(g.Key.Kind, g.Key.Unit, [.. g.Select(m => m.Id)])), ]; private static int IndexOf(AnalysisMetric metric) { var index = AnalysisPageOptions.QuantityOrder.ToList().IndexOf(metric); return index < 0 ? int.MaxValue : index; } }