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;
}
}