using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Costing;
///
/// One cost request from catalog to priced result: which lines, standing charges and manual costs the scope holds, the
/// quantities they need (read once, through the analysis reader), and their prices (the Core calculator).
///
///
///
/// The order is fixed: catalog, tariffs, manual costs and categories are loaded once → the bill is classified with the
/// tariff book (D-35) → the scope becomes pricing groups (a type's bill, a category, a meter) → the buckets are planned
/// and cut into local months (D-36) → the quantities of every meter any group prices are read in one reader pass → each
/// group is priced twice: per bucket, and over the period's own months for its total.
///
///
/// A figure that is the sum of several groups (the portfolio: every type's bill plus the global standing charge and the
/// meterless manual costs) is added up with , so it reconciles with its parts exactly.
///
///
internal sealed class BillRun
{
private readonly MeterVaultDbContext _db;
private readonly AnalysisReader _reader;
private readonly CostAnalysisRequest _request;
private readonly ResolvedPeriod _period;
private readonly TimeZoneInfo _zone;
private readonly string _currency;
private readonly DateOnly _today;
private readonly HashSet _notCosted = [];
private readonly Dictionary _firstData = [];
private readonly List _quantityProblems = [];
private AnalysisCatalog _catalog = null!;
private TariffBook _book = null!;
private TotalsClassification _full = null!;
private List _manual = [];
private List _categories = [];
private Dictionary> _virtualSources = [];
private BucketPlan _plan = null!;
private AnalysisBucket _periodBucket = null!;
private IReadOnlyList _displayParts = [];
private IReadOnlyList _periodParts = [];
private Dictionary> _displayValues = [];
private Dictionary> _periodValues = [];
private Dictionary> _displaySpans = [];
private Dictionary> _periodSpans = [];
private Dictionary _series = [];
public BillRun(MeterVaultDbContext db, AnalysisReader reader, CostAnalysisRequest request, string currency)
{
_db = db;
_reader = reader;
_request = request;
_period = request.Period;
_zone = reader.Zone;
_currency = currency;
_today = PeriodResolver.LocalDate(_period.Now, _zone);
}
private bool WantsCategories =>
_request.Scope.Kind == CostScopeKind.Category || (_request.Scope.Kind == CostScopeKind.Portfolio && _request.IncludeCategories);
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
await LoadAsync(WantsCategories, cancellationToken).ConfigureAwait(false);
if (ScopeFor() is not { } scope)
{
return Refused(EmptyPlan(), CostRefusal.UnknownScope);
}
var plan = await PlanAsync(scope, cancellationToken).ConfigureAwait(false);
if (plan.Refused)
{
return Refused(plan, CostRefusal.TooManyPoints);
}
_plan = plan;
_periodBucket = PeriodBucket.Of(_period);
_displayParts = CostCalculator.Parts(plan.Buckets);
_periodParts = CostCalculator.Parts([_periodBucket]);
await ReadQuantitiesAsync(scope, cancellationToken).ConfigureAwait(false);
await LoadServiceAsync(cancellationToken).ConfigureAwait(false);
foreach (var group in scope.AllGroups())
{
Price(group);
}
return Compose(scope);
}
/// What a scope has data for (D-19): its priced meters' coverage and its manual costs, without pricing.
public async Task AvailabilityAsync(CancellationToken cancellationToken)
{
await LoadAsync(WantsCategories, cancellationToken).ConfigureAwait(false);
if (ScopeFor() is not { } scope)
{
return CostAvailability.None;
}
var metered = await _reader.QuantityAvailabilityAsync(_db, _catalog, scope.AvailabilityMeters, _period.Now, cancellationToken)
.ConfigureAwait(false);
return Availability(metered, scope.ManualInScope);
}
// ------------------------------------------------------------------------------------------------ loading
private async Task LoadAsync(bool categories, CancellationToken cancellationToken)
{
_catalog = await AnalysisCatalog.LoadAsync(_db, _zone, cancellationToken).ConfigureAwait(false);
// One tariff load per request; ordered so that the book's deterministic tie-break never depends on the plan.
var tariffs = await _db.Tariffs.AsNoTracking().OrderBy(t => t.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
_book = TariffBook.Create(tariffs, _currency);
_full = _catalog.Classify(_book.HasMeterScopedUnitPrice);
_virtualSources = _catalog.TotalsMeters.Where(m => m.IsVirtual).ToDictionary(m => m.Id, m => m.VirtualSources);
_manual = await _db.ManualCosts.AsNoTracking().OrderBy(c => c.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
if (categories)
{
_categories = await _db.CostCategories.AsNoTracking()
.Include(c => c.Members)
.OrderBy(c => c.Sort)
.ThenBy(c => c.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
}
// ------------------------------------------------------------------------------------------------ scope → groups
/// The scope as pricing groups, or null when its meter or category does not exist.
private ScopePlan? ScopeFor()
{
var scope = _request.Scope;
switch (scope.Kind)
{
case CostScopeKind.Portfolio:
{
var plan = new ScopePlan { AvailabilityMeters = [.. _full.BillItems.Order()], ManualInScope = [.. _manual] };
foreach (var type in TypesWithMeters())
{
plan.Types.Add((type, TypeGroup(type)));
}
plan.Portfolio = new PricingGroup();
if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Global, null))
{
plan.Portfolio.Rows.Add(new StandingChargeKey(TariffScope.Global, null));
}
plan.Portfolio.Manual.AddRange(_manual.Where(c => c.MeterId is not { } id || _catalog.Find(id) is null));
if (_request.IncludeCategories)
{
plan.Categories = PlanCategories();
}
return plan;
}
case CostScopeKind.EnergyType:
{
var type = scope.Id!.Value;
var plan = new ScopePlan
{
AvailabilityMeters = [.. _full.ForType(type).Billing.Items.Order()],
ManualInScope = [.. _manual.Where(c => MeterType(c.MeterId) == type)],
};
plan.Types.Add((type, TypeGroup(type)));
return plan;
}
case CostScopeKind.Meter:
{
if (_catalog.Find(scope.Id!.Value) is not { } meter)
{
return null;
}
var (group, info) = MeterGroup(meter);
return new ScopePlan
{
Single = group,
Meter = info,
AvailabilityMeters = [meter.Id],
ManualInScope = [.. group.Manual],
OwnSemantics = !meter.IsVirtual,
};
}
default:
{
if (_categories.Find(c => c.Id == scope.Id) is null)
{
return null;
}
var categories = PlanCategories();
var entry = categories.Entries[scope.Id!.Value];
return new ScopePlan
{
Single = entry.Group,
CategoryEntry = entry,
Categories = categories,
AvailabilityMeters = entry.Cover.CoverMeterIds,
ManualInScope = [.. entry.Group.Manual],
};
}
}
}
private IEnumerable TypesWithMeters() => _catalog.Meters.Values.Select(m => m.EnergyTypeId).Distinct().Order();
private int? MeterType(int? meterId) => meterId is { } id && _catalog.Find(id) is { } meter ? meter.EnergyTypeId : null;
/// A type's bill (D-34, D-35): its lines, its own standing charge (D-40), the manual costs of its meters (D-41).
private PricingGroup TypeGroup(int type)
{
var billing = _full.ForType(type).Billing;
var group = new PricingGroup { EnergyTypeId = type, Basis = billing.Basis };
foreach (var line in billing.Lines)
{
group.Lines.AddRange(Expand(line, billing.SeparatelyBilled));
}
if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.EnergyType, type))
{
group.Rows.Add(new StandingChargeKey(TariffScope.EnergyType, type));
}
group.Rows.AddRange(MeterFeeRows(type, group.Lines));
group.Manual.AddRange(_manual.Where(c => MeterType(c.MeterId) == type));
return group;
}
///
/// A-18: the meter-scoped standing charges of a type's physical meters that no line of
/// accrues — a PV or house meter behind the billed grid meter — each as a row of its own on its meter, so a meter fee
/// is charged once, on its meter (D-40), rather than dropped because its meter is not a bill line.
///
private List MeterFeeRows(int type, IEnumerable lines)
{
var lined = lines.Select(l => l.MeterId).ToHashSet();
return [.. _catalog.Meters.Values
.Where(m => m.EnergyTypeId == type && !m.IsVirtual && !lined.Contains(m.Id)
&& _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, m.Id))
.Select(m => m.Id)
.Order()
.Select(id => new StandingChargeKey(TariffScope.Meter, id))];
}
///
/// A meter's own cost (D-39 for virtual meters): the line the bill prices it with, else a view at its unit price or
/// its feed-in credit; a virtual meter by its cost rule. Its manual costs go with it (D-41).
///
private (PricingGroup Group, MeterCostInfo Info) MeterGroup(AnalysisMeter meter)
{
var group = new PricingGroup();
group.Manual.AddRange(_manual.Where(c => c.MeterId == meter.Id));
var line = _full.LineOf(meter.Id);
if (!meter.IsVirtual)
{
var own = OwnLines(meter);
group.Lines.AddRange(own.Lines);
// A meter the scope prices no quantity of (a generation meter) still carries its own fee (A-18).
if (own.Lines.Count == 0 && _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, meter.Id))
{
group.Rows.Add(new StandingChargeKey(TariffScope.Meter, meter.Id));
}
return (group, new MeterCostInfo(meter.Id, own.Rule, own.OnBill, own.Reason, []));
}
if (meter.Formula is null)
{
return (group, Info(MeterCostRule.None, line is not null, MeterNotCostedReason.NotEvaluable));
}
var rule = meter.CostRule switch
{
VirtualCostRule.SourceCosts => MeterCostRule.SourceCosts,
VirtualCostRule.OwnQuantity => MeterCostRule.OwnQuantity,
_ => MeterCostRule.None,
};
// A stored source-costs rule over a nested difference is taken as none (A-15), and a generation result is never
// billed (D-34): say why rather than "no rule".
var notCosted = rule != MeterCostRule.None ? MeterNotCostedReason.None
: meter.Validation?.CostRuleProblem is not null ? MeterNotCostedReason.SourcesNotPureSum
: meter.Quantity.Kind == QuantityKind.Generation ? MeterNotCostedReason.Generation
: MeterNotCostedReason.NoCostRule;
if (line is not null)
{
group.Lines.AddRange(Expand(line, SeparatelyBilledOf(meter.EnergyTypeId)));
IReadOnlyList billedSources = rule == MeterCostRule.SourceCosts
? [.. group.Lines.Select(l => l.MeterId).Distinct().Order()]
: [];
return (group, new MeterCostInfo(meter.Id, rule, true, notCosted, billedSources));
}
if (rule == MeterCostRule.SourceCosts)
{
if (CostSourcesOf(meter.Id) is not { } sourceIds)
{
return (group, Info(MeterCostRule.None, false, MeterNotCostedReason.SourcesNotPureSum));
}
var lines = SourceLines(meter, [], []);
if (lines.Count == 0)
{
// D-39 adds the sources' own costs; generation and runtime sources have none (D-34).
return (group, Info(MeterCostRule.None, false, NotCostedReasonOf(sourceIds)));
}
group.Lines.AddRange(lines);
return (group, new MeterCostInfo(meter.Id, rule, false, notCosted, [.. lines.Select(l => l.MeterId).Distinct().Order()]));
}
if (rule == MeterCostRule.OwnQuantity)
{
group.Lines.Add(new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.UnitPrice, meter.Quantity.Unit, [], null));
}
return (group, new MeterCostInfo(meter.Id, rule, false, notCosted, []));
MeterCostInfo Info(MeterCostRule costRule, bool onBill, MeterNotCostedReason reason = MeterNotCostedReason.None) =>
new(meter.Id, costRule, onBill, reason, []);
}
///
/// What a physical meter's own scope prices: its line on the bill; otherwise a view by what it measures — its
/// consumption at its unit price, its export as a feed-in credit — or nothing, and why (generation and operating time
/// are never billed, D-34).
///
private OwnCost OwnLines(AnalysisMeter meter)
{
if (_full.LineOf(meter.Id) is { } line)
{
return new OwnCost([.. Expand(line, SeparatelyBilledOf(meter.EnergyTypeId))], MeterCostRule.BillLine, true, MeterNotCostedReason.None);
}
return meter.Quantity.Kind switch
{
QuantityKind.Consumption => new OwnCost(
[new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.UnitPrice, meter.Quantity.Unit, [], null)],
MeterCostRule.UnitPriceView, false, MeterNotCostedReason.None),
QuantityKind.Export => new OwnCost(
[new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.FeedIn, meter.Quantity.Unit, [], null)],
MeterCostRule.FeedInView, false, MeterNotCostedReason.None),
QuantityKind.Generation => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.Generation),
QuantityKind.Runtime => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.Runtime),
_ => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.NoCostRule),
};
}
/// Why a sum whose sources price nothing has no cost: all generation, all operating time, or no rule at all.
private MeterNotCostedReason NotCostedReasonOf(IReadOnlyList sourceIds)
{
var kinds = sourceIds.Select(id => _catalog.Meters[id].Quantity.Kind).Distinct().ToList();
return kinds switch
{
[QuantityKind.Generation] => MeterNotCostedReason.Generation,
[QuantityKind.Runtime] => MeterNotCostedReason.Runtime,
_ => MeterNotCostedReason.NoCostRule,
};
}
private IReadOnlyList SeparatelyBilledOf(int type) => _full.ForType(type).Billing.SeparatelyBilled;
///
/// A bill line as priced lines. A physical meter is its own line. A virtual meter counted by an override (D-23) is
/// priced by its cost rule (D-39): its own quantity, or its sources' metered costs — one line per source, each at
/// its own price, never the sum repriced; with the rule none it is left out and reported.
///
private IEnumerable Expand(BillLine line, IReadOnlyList separately, bool report = true)
{
var meter = _catalog.Meters[line.MeterId];
if (!meter.IsVirtual || meter.CostRule == VirtualCostRule.OwnQuantity)
{
return [new LineSpec(meter.Id, meter.EnergyTypeId, line.Kind, meter.Quantity.Unit, line.Deductions, null)];
}
if (meter.CostRule == VirtualCostRule.SourceCosts)
{
return SourceLines(meter, line.Deductions, separately);
}
if (report)
{
_notCosted.Add(meter.Id);
}
return [];
}
///
/// D-39 sourceCosts: each physical source priced the way its own scope prices it (A-15) — a consumption source
/// at its unit price (or its bill line), an export source as a feed-in credit, a generation or runtime source not at
/// all — once per source the formula's weights name (). A separately billed subsection the
/// bill takes out of the sum is taken out of the source it runs through.
///
private List SourceLines(AnalysisMeter meter, IReadOnlyList deductions, IReadOnlyList separately)
{
var sources = CostSourcesOf(meter.Id) ?? [];
var perSource = sources.Select(_ => new List()).ToList();
foreach (var deduction in deductions)
{
var through = separately.FirstOrDefault(s => s.MeterId == deduction.MeterId && s.SubtractFromMeterId == meter.Id)?.ThroughMeterId;
var index = through is { } t ? Math.Max(0, sources.ToList().IndexOf(t)) : 0;
if (perSource.Count == 0)
{
break;
}
var factor = UnitFactor(deduction.MeterId, sources[index]) ?? deduction.UnitFactor;
perSource[index].Add(new BillDeduction(deduction.MeterId, factor));
}
var lines = new List();
for (var i = 0; i < sources.Count; i++)
{
var own = OwnLines(_catalog.Meters[sources[i]]).Lines;
for (var j = 0; j < own.Count; j++)
{
lines.Add(own[j] with { Deductions = j == 0 ? [.. own[j].Deductions, .. perSource[i]] : own[j].Deductions, ForMeterId = meter.Id });
}
}
return lines;
}
///
/// The physical sources whose metered costs a sourceCosts meter adds (D-39): the meters its formula's weights
/// name — each once, so m1 + m1 - m1 + m2 is m1 and m2 — through nested pure sums. Null when a nested source
/// is not a pure sum: its sources' costs would include a subtrahend's (A-15).
///
private List? CostSourcesOf(int virtualId)
{
var result = new List();
var visiting = new HashSet();
return Collect(virtualId) ? result : null;
bool Collect(int id)
{
if (_catalog.Find(id) is not { } meter)
{
return true;
}
if (!meter.IsVirtual)
{
result.Add(id);
return true;
}
if (meter.Formula is not { IsPureSum: true, Coefficients: { } weights } || !visiting.Add(id))
{
return false;
}
foreach (var source in weights.Keys.Order())
{
if (!Collect(source))
{
return false;
}
}
visiting.Remove(id);
return true;
}
}
private IReadOnlyList SourcesOf(int virtualId) =>
[.. (_virtualSources.GetValueOrDefault(virtualId) ?? []).Where(id => _catalog.Find(id) is { IsVirtual: false })];
private double? UnitFactor(int from, int to)
{
var fromUnit = _catalog.Meters[from].Quantity.Unit;
var toUnit = _catalog.Meters[to].Quantity.Unit;
return Units.AreSame(fromUnit, toUnit) ? 1d : Units.ConversionFactor(fromUnit, toUnit);
}
// ------------------------------------------------------------------------------------------------ categories (D-42)
///
/// Every category's cover, the parts of the bill each holds, which categories are slices of the bill and which are
/// overlapping views, and the composition's groups.
///
private CategoryPlan PlanCategories()
{
var plan = new CategoryPlan();
var billRows = BillRows();
var members = _categories.ToDictionary(c => c.Id, ExpandMembers);
var covers = _categories.Select(c => CategoryCover.Compute(_full, c.Id, members[c.Id])).ToList();
var report = CategoryCover.CheckOverlap(_full, covers);
var manualClaims = _manual.ToDictionary(c => c.Id, c => ClaimsOf(c, members));
var rowClaims = billRows.ToDictionary(r => r, r => _categories.Where(c => HoldsRow(c, r, members[c.Id])).Select(c => c.Id).ToList());
// A slice of the bill shares nothing with another slice: two line-disjoint categories claiming the same manual
// cost or standing charge would add it twice, so both become views.
var views = new HashSet(report.OverlappingViewIds);
var candidates = _categories.Select(c => c.Id).Where(id => !views.Contains(id)).ToHashSet();
foreach (var claimants in manualClaims.Values.Concat(rowClaims.Values))
{
var slices = claimants.Where(candidates.Contains).ToList();
if (slices.Count > 1)
{
views.UnionWith(slices);
}
}
plan.Overlaps = Overlaps(report, manualClaims, rowClaims);
var disjoint = _categories.Select(c => c.Id).Where(id => !views.Contains(id)).ToHashSet();
foreach (var (category, cover) in _categories.Zip(covers))
{
var group = new PricingGroup();
var separately = cover.SeparatelyBilled;
foreach (var line in cover.Lines)
{
group.Lines.AddRange(Expand(line, cover.LiesOutsideBill ? separately : SeparatelyBilledOf(_catalog.Meters[line.MeterId].EnergyTypeId)));
}
// A meter's own fee accrues on its line where the category prices one (A-18); only a meter without one needs its row.
group.Rows.AddRange(rowClaims
.Where(r => r.Value.Contains(category.Id))
.Select(r => r.Key)
.Where(r => r.Scope != TariffScope.Meter || !group.Lines.Exists(l => l.MeterId == r.ScopeId)));
group.Manual.AddRange(_manual.Where(c => manualClaims[c.Id].Contains(category.Id)));
plan.Entries[category.Id] = new CategoryEntry(category, cover, group, views.Contains(category.Id));
}
// Uncategorized: the bill lines and manual costs no slice holds; standing charges no slice holds are rows of their own.
var categorized = disjoint.SelectMany(id => plan.Entries[id].Cover.CoverMeterIds).ToHashSet();
plan.UncategorizedMeterIds = [.. _full.BillItems.Where(id => !categorized.Contains(id)).Order()];
foreach (var id in plan.UncategorizedMeterIds)
{
plan.Uncategorized.Lines.AddRange(Expand(_full.LineOf(id)!, SeparatelyBilledOf(_catalog.Meters[id].EnergyTypeId)));
}
plan.Uncategorized.Manual.AddRange(_manual.Where(c => !manualClaims[c.Id].Exists(disjoint.Contains)));
foreach (var row in billRows.Where(r => !rowClaims[r].Exists(disjoint.Contains)))
{
var group = new PricingGroup();
group.Rows.Add(row);
plan.RowSlices.Add((row, group));
}
plan.Disjoint = [.. _categories.Where(c => disjoint.Contains(c.Id)).Select(c => c.Id)];
return plan;
}
///
/// The standing-charge rows of the whole bill: each type with meters and a base price, the meter fees no bill line
/// accrues (A-18, as adds them), and the global one.
///
private List BillRows()
{
var rows = new List();
foreach (var type in TypesWithMeters())
{
if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.EnergyType, type))
{
rows.Add(new StandingChargeKey(TariffScope.EnergyType, type));
}
var lines = _full.ForType(type).Billing.Lines.SelectMany(l => Expand(l, SeparatelyBilledOf(type), report: false));
rows.AddRange(MeterFeeRows(type, lines));
}
if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Global, null))
{
rows.Add(new StandingChargeKey(TariffScope.Global, null));
}
return rows;
}
/// A category's members with type members expanded to every meter of the type, known meters only, ascending.
private IReadOnlyList ExpandMembers(CostCategory category)
{
var ids = new SortedSet();
foreach (var member in category.Members)
{
if (member.MeterId is { } meterId && _catalog.Find(meterId) is not null)
{
ids.Add(meterId);
}
if (member.EnergyTypeId is { } type)
{
ids.UnionWith(_catalog.Meters.Values.Where(m => m.EnergyTypeId == type).Select(m => m.Id));
}
}
return [.. ids];
}
///
/// The categories a manual cost belongs to (D-41): its own category, else every category holding its meter.
///
private List ClaimsOf(ManualCost cost, Dictionary> members)
{
if (cost.CategoryId is { } categoryId)
{
return members.ContainsKey(categoryId) ? [categoryId] : [];
}
return cost.MeterId is { } meterId
? [.. _categories.Where(c => members[c.Id].Contains(meterId)).Select(c => c.Id)]
: [];
}
///
/// D-42: a type's standing charge joins a category that holds the whole type — the type itself, or every physical
/// meter of it; the global one a category that holds every billed meter.
///
private bool HoldsRow(CostCategory category, StandingChargeKey row, IReadOnlyList members)
{
var set = members.ToHashSet();
if (row.Scope == TariffScope.Global)
{
return _full.BillItems.Count > 0 && _full.BillItems.All(set.Contains);
}
if (row.Scope == TariffScope.Meter)
{
// A meter's own fee goes with its meter (D-40, D-42).
return set.Contains(row.ScopeId!.Value);
}
var type = row.ScopeId!.Value;
if (category.Members.Any(m => m.EnergyTypeId == type))
{
return true;
}
var physical = _catalog.Meters.Values.Where(m => m.EnergyTypeId == type && !m.IsVirtual).Select(m => m.Id).ToList();
return physical.Count > 0 && physical.All(set.Contains);
}
private static List Overlaps(
CategoryOverlapReport report,
Dictionary> manualClaims,
Dictionary> rowClaims)
{
var pairs = new SortedDictionary<(int, int), (SortedSet Meters, SortedSet Manual, List Rows)>();
(SortedSet Meters, SortedSet Manual, List Rows) Pair(int a, int b)
{
var key = a < b ? (a, b) : (b, a);
if (!pairs.TryGetValue(key, out var pair))
{
pairs[key] = pair = ([], [], []);
}
return pair;
}
foreach (var overlap in report.Overlaps)
{
Pair(overlap.CategoryId, overlap.OtherCategoryId).Meters.UnionWith(overlap.SharedMeterIds);
}
foreach (var (manualId, claimants) in manualClaims)
{
ForEachPair(claimants, (a, b) => Pair(a, b).Manual.Add(manualId));
}
foreach (var (row, claimants) in rowClaims)
{
ForEachPair(claimants, (a, b) => Pair(a, b).Rows.Add(row));
}
return [.. pairs.Select(p => new CostCategoryOverlap(p.Key.Item1, p.Key.Item2, [.. p.Value.Meters], [.. p.Value.Manual], p.Value.Rows))];
static void ForEachPair(List ids, Action action)
{
for (var i = 0; i < ids.Count; i++)
{
for (var j = i + 1; j < ids.Count; j++)
{
action(ids[i], ids[j]);
}
}
}
}
// ------------------------------------------------------------------------------------------------ plan and quantities
///
/// The buckets (D-05): the caller's plan, the size asked for, or auto from the coverage of the priced meters. A line
/// without any tariff has no cost to chart, so its resolution (a tank's months-long dipstick intervals) does not
/// coarsen the chart; only when nothing has a tariff do all lines decide.
///
private async Task PlanAsync(ScopePlan scope, CancellationToken cancellationToken)
{
if (_request.Plan is { } given)
{
return given;
}
var lines = scope.AllGroups().SelectMany(g => g.Lines).ToList();
var withPrice = lines.Where(HasAnyPrice).ToList();
var priced = (withPrice.Count > 0 ? withPrice : lines).Select(l => l.MeterId).Distinct().Order().ToList();
if (_request.Bucket != BucketSize.Auto || priced.Count == 0)
{
return BucketPlanner.Plan(_period, _request.Bucket, maxPoints: _request.MaxPoints);
}
var request = new AnalysisRequest(AnalysisScope.ForMeters(priced), _period)
{
MaxPoints = _request.MaxPoints,
MaxSeries = int.MaxValue,
QuantitiesOnly = true,
};
return await _reader.PlanAsync(_db, _catalog, request, cancellationToken).ConfigureAwait(false);
}
private bool HasAnyPrice(LineSpec line) => line.Kind switch
{
BillLineKind.FeedIn => _book.HasAny(TariffComponent.FeedIn, line.MeterId, line.EnergyTypeId),
BillLineKind.OwnPrice => _book.HasAnyInScope(TariffComponent.UnitPrice, TariffScope.Meter, line.MeterId),
_ => _book.HasAny(TariffComponent.UnitPrice, line.MeterId, line.EnergyTypeId),
};
///
/// Reads every meter the groups price (with the subsections they deduct, and the meters the availability is taken
/// from) per local-month part (D-36): once for the buckets' parts, and — only when they differ — once for the
/// period's own months, which the total is priced from.
///
private async Task ReadQuantitiesAsync(ScopePlan scope, CancellationToken cancellationToken)
{
var lines = scope.AllGroups().SelectMany(g => g.Lines).ToList();
List ids =
[
.. lines.Select(l => l.MeterId)
.Concat(lines.SelectMany(l => l.Deductions.Select(d => d.MeterId)))
.Concat(lines.SelectMany(l => BasisCheckOf(l)?.UseMeterIds ?? []))
.Concat(scope.AvailabilityMeters)
.Where(id => _catalog.Find(id) is not null)
.Distinct()
.Order(),
];
if (ids.Count == 0)
{
return;
}
var contributors = !scope.OwnSemantics;
var display = await ReadPartsAsync(ids, _displayParts, _plan.Buckets, _plan.Size, contributors, cancellationToken).ConfigureAwait(false);
_series = display;
_displayValues = display.ToDictionary(p => p.Key, p => p.Value.Values);
if (SameParts())
{
_periodValues = _displayValues;
}
else
{
var period = await ReadPartsAsync(ids, _periodParts, [_periodBucket], _periodBucket.Size, contributors, cancellationToken).ConfigureAwait(false);
_periodValues = period.ToDictionary(p => p.Key, p => p.Value.Values);
}
// A-16: where a priced line's part is unresolved inside a bucket of several months, read the bucket whole too.
List spanned =
[
.. lines.Where(HasAnyPrice)
.SelectMany(l => l.Deductions.Select(d => d.MeterId).Prepend(l.MeterId))
.Where(id => _catalog.Find(id) is not null)
.Distinct()
.Order(),
];
if (NeedsSpans(_displayParts, _displayValues, spanned))
{
_displaySpans = await ReadSpansAsync(spanned, _plan.Buckets, _plan.Size, contributors, cancellationToken).ConfigureAwait(false);
}
if (NeedsSpans(_periodParts, _periodValues, spanned))
{
_periodSpans = await ReadSpansAsync(spanned, [_periodBucket], _periodBucket.Size, contributors, cancellationToken).ConfigureAwait(false);
}
}
/// True when a meter's part is unresolved inside a bucket that has several parts (A-16).
private static bool NeedsSpans(IReadOnlyList parts, Dictionary> values, IReadOnlyList ids)
{
var multi = parts.GroupBy(p => p.BucketIndex).Where(g => g.Count() > 1).Select(g => g.Key).ToHashSet();
if (multi.Count == 0)
{
return false;
}
return ids.Any(id => values.GetValueOrDefault(id) is { } series
&& parts.Select((part, index) => (part, index)).Any(p => multi.Contains(p.part.BucketIndex) && series[p.index].Status == BucketStatus.Unresolved));
}
/// The meters' values over whole buckets, one per bucket (A-16).
private async Task>> ReadSpansAsync(
IReadOnlyList ids, IReadOnlyList buckets, BucketSize size, bool contributors, CancellationToken cancellationToken)
{
List whole = [.. buckets.Select((b, i) => new CostPart(i, b.FirstDay, b.EndDay))];
var read = await ReadPartsAsync(ids, whole, buckets, size, contributors, cancellationToken).ConfigureAwait(false);
return read.ToDictionary(p => p.Key, p => p.Value.Values);
}
private async Task> ReadPartsAsync(
IReadOnlyList ids,
IReadOnlyList parts,
IReadOnlyList buckets,
BucketSize size,
bool contributors,
CancellationToken cancellationToken)
{
List partBuckets = [.. parts.Select(p => PartBucket(p, buckets[p.BucketIndex]))];
var request = new AnalysisRequest(AnalysisScope.ForMeters(ids), _period)
{
Plan = new BucketPlan(size, size, partBuckets, partBuckets.Count, Refused: false, Suggested: null),
MaxSeries = int.MaxValue,
AsContributors = contributors,
QuantitiesOnly = true,
};
var result = await _reader.ReadAsync(_db, _catalog, request, cancellationToken).ConfigureAwait(false);
foreach (var problem in result.Problems)
{
if (!_quantityProblems.Exists(p => p.Kind == problem.Kind && p.MeterId == problem.MeterId && p.Virtual?.Kind == problem.Virtual?.Kind))
{
_quantityProblems.Add(problem);
}
}
return result.Series.Where(s => s.MeterId is not null).ToDictionary(s => s.MeterId!.Value);
}
///
/// A part as a bucket the reader sums: its local days, clipped to its bucket's instants (the last part of a to-date
/// bucket ends at now), with its bucket's size, so it resolves exactly as its bucket would (D-14).
///
private AnalysisBucket PartBucket(CostPart part, AnalysisBucket bucket)
{
var start = GapAttribution.LocalMidnight(part.FirstDay, _zone);
var end = GapAttribution.LocalMidnight(part.EndDay, _zone);
var from = start > bucket.From ? start : bucket.From;
var to = end < bucket.To ? end : bucket.To;
return new AnalysisBucket(part.FirstDay, part.EndDay, from, to < from ? from : to, bucket.Size);
}
///
/// True when the buckets' parts are the period's months, evaluated alike: the same days, and sizes that resolve
/// them the same way (a month and a year both resolve by month).
///
private bool SameParts()
{
if (_displayParts.Count != _periodParts.Count)
{
return false;
}
static bool Monthly(BucketSize size) => size is BucketSize.Month or BucketSize.Year;
var sizesAgree = _plan.Size == _periodBucket.Size || (Monthly(_plan.Size) && Monthly(_periodBucket.Size));
return sizesAgree && _displayParts.Zip(_periodParts).All(p => p.First.FirstDay == p.Second.FirstDay && p.First.EndDay == p.Second.EndDay);
}
///
/// The first data day of every physical meter, for standing-charge service periods (D-40) — read only when there is
/// a base price to accrue.
///
private async Task LoadServiceAsync(CancellationToken cancellationToken)
{
if (!_book.Tariffs.Any(t => t.Component == TariffComponent.BasePrice))
{
return;
}
var ids = _catalog.Meters.Values.Where(m => !m.IsVirtual).Select(m => m.Id).ToList();
if (ids.Count == 0)
{
return;
}
await _db.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var runs = await AnalysisQueries.CoverageAsync(_db.Database.GetDbConnection(), ids, cancellationToken).ConfigureAwait(false);
foreach (var id in ids)
{
_firstData[id] = AvailableRange.OfRuns(runs.GetValueOrDefault(id) ?? [], _period.Now, _zone)?.FirstDay;
}
}
private ServicePeriod? MeterService(int meterId)
{
if (_catalog.Find(meterId) is not { } meter)
{
return null;
}
return meter.IsVirtual
? ServicePeriod.Span(SourcesOf(meterId).Distinct().Select(MeterService))
: ServicePeriod.ForMeter(meter.Meter.InstalledAt, meter.Meter.RetiredAt, _firstData.GetValueOrDefault(meterId));
}
private ServicePeriod? RowService(StandingChargeKey row) =>
row.Scope == TariffScope.Meter
? MeterService(row.ScopeId!.Value)
: ServicePeriod.Span(_catalog.Meters.Values
.Where(m => !m.IsVirtual && (row.Scope == TariffScope.Global || m.EnergyTypeId == row.ScopeId))
.Select(m => MeterService(m.Id)));
// ------------------------------------------------------------------------------------------------ pricing
/// Prices a group per bucket and over the period's own months.
private void Price(PricingGroup group)
{
var rows = group.Rows
.Select(r => new StandingChargeScope(r.Scope, r.ScopeId, RowService(r)))
.ToList();
var bucketLines = group.Lines.Select(l => Line(l, _displayParts, _displayValues, _plan.Buckets, _displaySpans, group.BasisGaps)).ToList();
var totalLines = group.Lines.Select(l => Line(l, _periodParts, _periodValues, [_periodBucket], _periodSpans, group.BasisGaps)).ToList();
group.ByBucket = CostCalculator.Calculate(new CostRequest(_plan.Buckets, _today, _book, bucketLines, rows, group.Manual));
group.OverPeriod = CostCalculator.Calculate(new CostRequest([_periodBucket], _today, _book, totalLines, rows, group.Manual));
foreach (var line in group.ByBucket.Lines.Concat(group.OverPeriod.Lines).Where(l => l.MonthsWithPriceChangeInsideInterval.Count > 0))
{
Months(group.PriceChanges, line.MeterId).UnionWith(line.MonthsWithPriceChangeInsideInterval);
}
for (var i = 0; i < group.Lines.Count; i++)
{
var spec = group.Lines[i];
var buckets = group.ByBucket.Lines[i];
var total = group.OverPeriod.Lines[i];
var perBucket = new double?[_plan.Buckets.Count];
for (var p = 0; p < _displayParts.Count; p++)
{
if (bucketLines[i].Quantities[p] is { IsKnown: true, Amount: { } amount })
{
var index = _displayParts[p].BucketIndex;
perBucket[index] = (perBucket[index] ?? 0) + amount;
}
}
var known = totalLines[i].Quantities.Where(q => q.IsKnown).Select(q => q.Amount!.Value).ToList();
group.Figures.Add(new CostLineFigure(
spec.MeterId,
_catalog.Meters[spec.MeterId].Name,
spec.EnergyTypeId,
spec.Kind,
spec.Unit,
buckets.Buckets,
total.Total,
perBucket,
known.Count > 0 ? known.Sum() : null)
{
Deductions = spec.Deductions,
MonthsWithoutOwnPrice = total.MonthsWithoutOwnPrice,
ForMeterId = spec.ForMeterId,
});
}
foreach (var (buckets, total) in group.ByBucket.StandingCharges.Zip(group.OverPeriod.StandingCharges))
{
group.RowFigures.Add(new StandingChargeFigure(
buckets.Scope, buckets.ScopeId, buckets.Buckets, total.Total, RowService(new StandingChargeKey(buckets.Scope, buckets.ScopeId))));
}
}
///
/// A line's net quantity per part (D-35): its meter's value less the subsections billed on their own that month. A
/// month the type's billing basis has no meter in service for is unknown, not the grid meter's zero (A-17). Buckets
/// read whole (A-16) give the line its spans.
///
private CostLine Line(
LineSpec spec,
IReadOnlyList parts,
Dictionary> values,
IReadOnlyList buckets,
Dictionary> spanValues,
Dictionary> basisGaps)
{
var own = values.GetValueOrDefault(spec.MeterId);
var basis = BasisCheckOf(spec);
var quantities = new List(parts.Count);
var gapBuckets = new HashSet();
for (var p = 0; p < parts.Count; p++)
{
var part = parts[p];
if (basis is not null && IsBasisGap(basis, part, p, values))
{
quantities.Add(CostQuantity.Unknown(part, BucketStatus.Missing));
Months(basisGaps, spec.MeterId).Add(part.Month);
gapBuckets.Add(part.BucketIndex);
continue;
}
var value = own?[p] ?? BucketValue.Missing();
List<(BucketValue Value, double Factor)> deducted = [];
foreach (var deduction in spec.Deductions)
{
if (_book.HasOwnUnitPrice(deduction.MeterId, part.Month))
{
deducted.Add((values.GetValueOrDefault(deduction.MeterId)?[p] ?? BucketValue.Missing(), deduction.UnitFactor));
}
}
quantities.Add(Net(part, value, deducted));
}
var service = _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, spec.MeterId) ? MeterService(spec.MeterId) : null;
return new CostLine(spec.MeterId, spec.EnergyTypeId, spec.Kind, spec.Unit, quantities, service)
{
Spans = Spans(spec, parts, buckets, spanValues, gapBuckets),
};
}
///
/// A line's quantity over each bucket of several parts that was read whole (A-16), net of the subsections billed on
/// their own — only where the same subsections are taken out in every month of the bucket — and never over a bucket
/// holding a basis gap.
///
private List? Spans(
LineSpec spec,
IReadOnlyList parts,
IReadOnlyList buckets,
Dictionary> spanValues,
HashSet gapBuckets)
{
if (spanValues.GetValueOrDefault(spec.MeterId) is not { } own)
{
return null;
}
var spans = new List();
foreach (var bucket in parts.GroupBy(p => p.BucketIndex).Where(g => g.Count() > 1))
{
var index = bucket.Key;
if (gapBuckets.Contains(index) || index >= own.Count)
{
continue;
}
var applied = bucket
.Select(part => spec.Deductions.Where(d => _book.HasOwnUnitPrice(d.MeterId, part.Month)).Select(d => d.MeterId).ToHashSet())
.ToList();
if (!applied.TrueForAll(set => set.SetEquals(applied[0])))
{
continue;
}
var deducted = spec.Deductions
.Where(d => applied[0].Contains(d.MeterId))
.Select(d => (spanValues.GetValueOrDefault(d.MeterId)?[index] ?? BucketValue.Missing(), d.UnitFactor))
.ToList();
var whole = new CostPart(index, buckets[index].FirstDay, buckets[index].EndDay);
var net = Net(whole, own[index], deducted);
spans.Add(new CostSpan(index, net.Amount, net.Availability));
}
return spans.Count > 0 ? spans : null;
}
///
/// A-17: for a grid-import line of a type billed on its grid import, the grid meters whose service decides the basis,
/// and the use meters whose data shows a month the basis misses. Null for any other line, and when no billed grid
/// meter has an install or retire date (then it covers every month).
///
private BasisCheck? BasisCheckOf(LineSpec spec)
{
if (spec.Kind != BillLineKind.UnitPrice || spec.ForMeterId is not null)
{
return null;
}
var totals = _full.ForType(spec.EnergyTypeId);
var billing = totals.Billing;
if (billing.Basis != BillingBasis.GridImport || !billing.BilledMeterIds.Contains(spec.MeterId))
{
return null;
}
var grids = billing.BilledMeterIds.Select(id => _catalog.Meters[id].Meter).ToList();
var use = totals.MetersIn(TotalsMeasure.Use).Where(id => _catalog.Find(id) is { IsVirtual: false }).ToList();
if (use.Count == 0 || grids.TrueForAll(m => m.InstalledAt is null && m.RetiredAt is null))
{
return null;
}
return new BasisCheck([.. grids.Select(m => (m.InstalledAt ?? DateOnly.MinValue, m.RetiredAt ?? DateOnly.MaxValue))], use);
}
///
/// True when some day of has no billed grid meter in service while a use meter in service
/// that month measured something (A-17): the grid meter's known zero would pass the use off as free.
///
private bool IsBasisGap(BasisCheck basis, CostPart part, int index, Dictionary> values)
{
var covered = true;
for (var day = part.FirstDay; day < part.EndDay && covered; day = day.AddDays(1))
{
covered = basis.GridService.Exists(w => w.First <= day && day <= w.Last);
}
if (covered)
{
return false;
}
return basis.UseMeterIds.Any(id =>
{
var meter = _catalog.Meters[id].Meter;
var inService = (meter.InstalledAt ?? DateOnly.MinValue) < part.EndDay && (meter.RetiredAt ?? DateOnly.MaxValue) >= part.FirstDay;
var value = values.GetValueOrDefault(id)?[index];
return inService && value is not null && value.Status != BucketStatus.Missing
&& !(value.Status == BucketStatus.Available && value.Value is { } amount && Math.Abs(amount) <= ProvenanceRules.Epsilon);
});
}
private static SortedSet Months(Dictionary> map, int meterId)
{
if (!map.TryGetValue(meterId, out var months))
{
map[meterId] = months = [];
}
return months;
}
///
/// A meter's value less deducted values: unknown as soon as any of them is (the strictest status wins, as for a
/// virtual difference, D-27), partial when any is partial.
///
internal static CostQuantity Net(CostPart part, BucketValue value, IReadOnlyList<(BucketValue Value, double Factor)> deducted)
{
if (deducted.Count == 0)
{
return value.Value is { } plain && value.Status is BucketStatus.Available or BucketStatus.Partial
? CostQuantity.Known(part, plain, value.Status)
: CostQuantity.Unknown(part, UnknownStatus(value.Status));
}
var all = deducted.Select(d => d.Value).Prepend(value).ToList();
foreach (var status in (ReadOnlySpan)[BucketStatus.Pending, BucketStatus.Invalid, BucketStatus.Unresolved])
{
if (all.Exists(v => v.Status == status))
{
return CostQuantity.Unknown(part, status);
}
}
if (all.Exists(v => v.Value is null || v.Status is not (BucketStatus.Available or BucketStatus.Partial)))
{
return CostQuantity.Unknown(part, BucketStatus.Missing);
}
var net = value.Value!.Value - deducted.Sum(d => d.Value.Value!.Value * d.Factor);
return CostQuantity.Known(part, net, all.Exists(v => v.Status == BucketStatus.Partial) ? BucketStatus.Partial : BucketStatus.Available);
}
private static BucketStatus UnknownStatus(BucketStatus status) =>
status is BucketStatus.Available or BucketStatus.Partial ? BucketStatus.Missing : status;
// ------------------------------------------------------------------------------------------------ result
private CostAnalysis Compose(ScopePlan scope)
{
var groups = scope.Groups().ToList();
var buckets = Enumerable.Range(0, _plan.Buckets.Count)
.Select(b => CostAmount.Sum(groups.Select(g => g.ByBucket.Totals[b])))
.ToList();
var total = CostAmount.Sum(groups.Select(g => g.OverPeriod.Total));
var manual = new ManualCostFigure(
[.. groups.SelectMany(g => g.ByBucket.ManualCosts.Bookings).OrderBy(b => b.Day).ThenBy(b => b.ManualCostId)],
AfterToday(groups),
[.. Enumerable.Range(0, _plan.Buckets.Count).Select(b => CostAmount.Sum(groups.Select(g => g.ByBucket.ManualCosts.Buckets[b])))],
CostAmount.Sum(groups.Select(g => g.OverPeriod.ManualCosts.Total)));
var warnings = Warnings(groups);
var result = new CostAnalysis(
_request,
_plan,
_book.Currency,
buckets,
total,
[.. groups.SelectMany(g => g.Figures)],
[.. groups.SelectMany(g => g.RowFigures)],
manual,
Availability(MeteredAvailability(scope), scope.ManualInScope),
Attention(scope, total, manual, warnings))
{
Warnings = warnings,
QuantityProblems = _quantityProblems,
EnergyTypes = [.. scope.Types.Select(t => new EnergyTypeCostFigure(
t.EnergyTypeId, t.Group.Basis, t.Group.ByBucket.Totals, t.Group.OverPeriod.Total, [.. t.Group.Lines.Select(l => l.MeterId).Distinct()]))],
Meter = scope.Meter,
};
if (scope.Categories is { } categories)
{
if (scope.CategoryEntry is { } entry)
{
result = result with { Category = Figure(entry, categories) };
}
else
{
result = result with { Composition = Composition(categories) };
}
}
return result;
}
private CategoryComposition Composition(CategoryPlan plan)
{
var slices = new List();
foreach (var id in plan.Disjoint)
{
var entry = plan.Entries[id];
slices.Add(new CompositionSlice(CompositionSliceKind.Category, id, null, entry.Group.ByBucket.Totals, entry.Group.OverPeriod.Total)
{
MeterIds = entry.Cover.CoverMeterIds,
ManualCostIds = [.. entry.Group.Manual.Select(c => c.Id)],
});
}
slices.Add(new CompositionSlice(CompositionSliceKind.Uncategorized, null, null, plan.Uncategorized.ByBucket.Totals, plan.Uncategorized.OverPeriod.Total)
{
MeterIds = plan.UncategorizedMeterIds,
ManualCostIds = [.. plan.Uncategorized.Manual.Select(c => c.Id)],
});
foreach (var (row, group) in plan.RowSlices)
{
slices.Add(new CompositionSlice(CompositionSliceKind.StandingCharge, null, row, group.ByBucket.Totals, group.OverPeriod.Total));
}
return new CategoryComposition(
slices,
[.. _categories.Select(c => Figure(plan.Entries[c.Id], plan))],
plan.Overlaps,
[.. Enumerable.Range(0, _plan.Buckets.Count).Select(b => CostAmount.Sum(slices.Select(s => s.Buckets[b])))],
CostAmount.Sum(slices.Select(s => s.Total)));
}
private static CategoryCostFigure Figure(CategoryEntry entry, CategoryPlan plan)
{
var id = entry.Category.Id;
return new CategoryCostFigure(
id,
entry.Category.Name,
entry.Category.ColorHex,
entry.Category.Sort,
entry.Cover,
entry.Group.ByBucket.Totals,
entry.Group.OverPeriod.Total)
{
IsOverlappingView = entry.IsView,
OverlapsWith = [.. plan.Overlaps
.Where(o => o.CategoryId == id || o.OtherCategoryId == id)
.Select(o => o.CategoryId == id ? o.OtherCategoryId : o.CategoryId)
.Distinct()
.Order()],
ManualCostIds = [.. entry.Group.Manual.Select(c => c.Id)],
StandingCharges = entry.Group.Rows,
};
}
///
/// The manual costs of the figure that start after today but inside the range the period names (D-41, compare
/// D-04): the buckets stop at now, so they are in none, and are reported rather than silently left out.
///
private List AfterToday(IEnumerable groups)
{
var last = _period.Preset == PeriodPreset.AllHistory ? DateOnly.MaxValue : _period.NominalLastDay();
return
[
.. groups.SelectMany(g => g.Manual)
.Where(c => c.PeriodStart > _today && c.PeriodStart >= _period.FirstDay && c.PeriodStart <= last)
.Select(c => c.Id)
.Concat(groups.SelectMany(g => g.OverPeriod.ManualCosts.AfterTodayIds))
.Distinct()
.Order(),
];
}
/// The coverage of the scope's priced meters, from the series the quantities were read with.
private AvailableRange? MeteredAvailability(ScopePlan scope) =>
AvailableRange.Union(scope.AvailabilityMeters.Select(id => _series.GetValueOrDefault(id)?.Availability), _zone);
/// D-19: the priced meters' coverage and the manual costs' start days up to today, and the latest month of either.
private CostAvailability Availability(AvailableRange? metered, IReadOnlyList manual)
{
var days = manual.Where(c => c.PeriodStart <= _today).Select(c => c.PeriodStart).ToList();
AvailableRange? manualRange = null;
if (days.Count > 0)
{
var from = GapAttribution.LocalMidnight(days.Min(), _zone);
var to = GapAttribution.LocalMidnight(days.Max().AddDays(1), _zone);
manualRange = AvailableRange.Of(from, to < _period.Now ? to : _period.Now, _zone) ?? AvailableRange.Of(from, to, _zone);
}
var range = AvailableRange.Union([metered, manualRange], _zone);
LatestPeriod? latest = null;
if (range is not null)
{
var month = range.LatestMonth;
var byMeters = metered?.LatestMonth == month;
var byManual = manualRange?.LatestMonth == month;
latest = new LatestPeriod(month, byMeters && byManual ? LatestPeriodBasis.Both : byManual ? LatestPeriodBasis.Manual : LatestPeriodBasis.Meters);
}
return new CostAvailability(metered, manualRange, range, latest);
}
/// Tariffs applied with an unchecked unit, each once with the first month it priced.
private static List Warnings(IEnumerable groups) =>
[.. groups
.SelectMany(g => g.OverPeriod.Warnings)
.GroupBy(w => (w.TariffId, w.Component, w.Issue, w.MeterId))
.Select(g => g.MinBy(w => w.FirstMonth)!)
.OrderBy(w => w.FirstMonth)
.ThenBy(w => w.TariffId)
.ThenBy(w => w.MeterId ?? int.MaxValue)];
/// The cost attention items (D-53) of the result.
private List Attention(ScopePlan scope, CostAmount total, ManualCostFigure manual, IReadOnlyList warnings)
{
var items = new List();
items.AddRange(total.MissingPrices.Select(m => new CostAttention(CostAttentionKind.MissingPrice, m.MeterId) { Price = m }));
items.AddRange(warnings.Select(w => new CostAttention(CostAttentionKind.UnverifiedTariffUnit, w.MeterId) { Warning = w }));
if (manual.AfterTodayIds.Count > 0)
{
items.Add(new CostAttention(CostAttentionKind.ManualCostAfterToday, null) { ManualCostIds = manual.AfterTodayIds });
}
var foreign = manual.Bookings.Where(b => b.CurrencyMismatch).Select(b => b.ManualCostId).Distinct().Order().ToList();
if (foreign.Count > 0)
{
items.Add(new CostAttention(CostAttentionKind.ManualCostCurrency, null) { ManualCostIds = foreign });
}
items.AddRange(_notCosted.Order().Select(id => new CostAttention(CostAttentionKind.VirtualNotCosted, id)));
var groups = scope.Groups().ToList();
foreach (var (kind, map) in new[]
{
(CostAttentionKind.PriceChangeInsideInterval, Merge(groups.Select(g => g.PriceChanges))),
(CostAttentionKind.BillingBasisGap, Merge(groups.Select(g => g.BasisGaps))),
})
{
items.AddRange(map.OrderBy(m => m.Key).Select(m => new CostAttention(kind, m.Key) { FirstMonth = m.Value.Min, LastMonth = m.Value.Max }));
}
items.AddRange(_full.Problems
.Where(p => p.Kind is TotalsProblemKind.UnusedMeterPrice or TotalsProblemKind.SeparateBillingUnitMismatch && InScope(scope, p.MeterId))
.Select(p => new CostAttention(CostAttentionKind.BillingConfiguration, p.MeterId) { Totals = p }));
// A category whose members give it no line — calculated views, generation, operating hours (D-39, D-42) — reads
// empty; say why rather than let it pass for a category without data (A-22).
IEnumerable entries = scope.CategoryEntry is { } own
? [own]
: scope.Categories?.Entries.Values.OrderBy(e => e.Category.Sort).ThenBy(e => e.Category.Id) ?? Enumerable.Empty();
foreach (var entry in entries)
{
if (entry.Cover.CoverMeterIds.Count == 0 && entry.Cover.AnalysisOnlyMeterIds.Count > 0)
{
items.Add(new CostAttention(CostAttentionKind.CategoryPricesNothing, entry.Cover.AnalysisOnlyMeterIds[0])
{
CategoryId = entry.Category.Id,
MeterIds = entry.Cover.AnalysisOnlyMeterIds,
});
}
}
return items;
}
private static Dictionary> Merge(IEnumerable>> maps)
{
var merged = new Dictionary>();
foreach (var (meter, months) in maps.SelectMany(m => m))
{
Months(merged, meter).UnionWith(months);
}
return merged;
}
/// Whether a meter's billing problem belongs to the scope: its meter, its category's members, its type, or anything.
private bool InScope(ScopePlan scope, int meterId)
{
if (scope.Meter is { } meter)
{
return meterId == meter.MeterId;
}
if (scope.CategoryEntry is { } entry)
{
return entry.Cover.MemberIds.Contains(meterId);
}
return scope.Portfolio is not null || scope.Types.Exists(t => t.EnergyTypeId == _catalog.Find(meterId)?.EnergyTypeId);
}
private CostAnalysis Refused(BucketPlan plan, CostRefusal refusal) =>
new(
_request,
plan,
_currency,
[],
CostAmount.Empty,
[],
[],
new ManualCostFigure([], [], [], CostAmount.Empty),
CostAvailability.None,
[])
{
Refusal = refusal,
};
private BucketPlan EmptyPlan()
{
var size = _request.Bucket == BucketSize.Auto ? BucketSize.Day : _request.Bucket;
return new BucketPlan(_request.Bucket, size, [], 0, Refused: false, Suggested: null);
}
// ------------------------------------------------------------------------------------------------ plans
/// One priced line: whose quantity, in which unit, how, less what.
private sealed record LineSpec(
int MeterId,
int EnergyTypeId,
BillLineKind Kind,
string Unit,
IReadOnlyList Deductions,
int? ForMeterId);
/// What a physical meter's own scope prices, and how its cost is named.
private sealed record OwnCost(List Lines, MeterCostRule Rule, bool OnBill, MeterNotCostedReason Reason);
/// The service windows of a type's billed grid meters (inclusive local days) and its use meters (A-17).
private sealed record BasisCheck(List<(DateOnly First, DateOnly Last)> GridService, IReadOnlyList UseMeterIds);
/// Lines, standing-charge rows and manual costs priced together, and their priced figures.
private sealed class PricingGroup
{
public int? EnergyTypeId { get; init; }
public BillingBasis Basis { get; init; }
public List Lines { get; } = [];
public List Rows { get; } = [];
public List Manual { get; } = [];
public CostResult ByBucket { get; set; } = null!;
public CostResult OverPeriod { get; set; } = null!;
public List Figures { get; } = [];
public List RowFigures { get; } = [];
/// Per line meter: the months whose interval could not be priced because the price changes inside it (A-16).
public Dictionary> PriceChanges { get; } = [];
/// Per grid meter: the months its type's grid basis missed while use was measured (A-17).
public Dictionary> BasisGaps { get; } = [];
}
private sealed record CategoryEntry(CostCategory Category, CategoryCoverResult Cover, PricingGroup Group, bool IsView);
/// Every category's figure and the bill's composition by them.
private sealed class CategoryPlan
{
public Dictionary Entries { get; } = [];
public IReadOnlyList Disjoint { get; set; } = [];
public PricingGroup Uncategorized { get; } = new();
public IReadOnlyList UncategorizedMeterIds { get; set; } = [];
public List<(StandingChargeKey Row, PricingGroup Group)> RowSlices { get; } = [];
public IReadOnlyList Overlaps { get; set; } = [];
/// The groups the composition prices (every category, Uncategorized, the rows of their own).
public IEnumerable Groups() =>
Entries.Values.Select(e => e.Group).Append(Uncategorized).Concat(RowSlices.Select(r => r.Group));
}
/// A scope as pricing groups: the groups its figure adds up, and whatever else it prices alongside.
private sealed class ScopePlan
{
public List<(int EnergyTypeId, PricingGroup Group)> Types { get; } = [];
public PricingGroup? Portfolio { get; set; }
public PricingGroup? Single { get; init; }
public MeterCostInfo? Meter { get; init; }
public CategoryEntry? CategoryEntry { get; init; }
public CategoryPlan? Categories { get; set; }
public IReadOnlyList AvailabilityMeters { get; init; } = [];
public IReadOnlyList ManualInScope { get; init; } = [];
/// A physical meter's own page: outside its service period it has no data, as its quantity says.
public bool OwnSemantics { get; init; }
/// The groups whose sum is the scope's figure.
public IEnumerable Groups()
{
if (Single is not null)
{
yield return Single;
yield break;
}
foreach (var (_, group) in Types)
{
yield return group;
}
if (Portfolio is not null)
{
yield return Portfolio;
}
}
/// Everything the request prices: the scope's groups, and for the portfolio the composition's.
public IEnumerable AllGroups() =>
Single is null && Categories is not null ? Groups().Concat(Categories.Groups()) : Groups();
}
}