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.
1560 lines
66 KiB
C#
1560 lines
66 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <see cref="CostAmount.Sum"/>, so it reconciles with its parts exactly.
|
|
/// </para>
|
|
/// </remarks>
|
|
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<int> _notCosted = [];
|
|
private readonly Dictionary<int, DateOnly?> _firstData = [];
|
|
private readonly List<AnalysisProblem> _quantityProblems = [];
|
|
|
|
private AnalysisCatalog _catalog = null!;
|
|
private TariffBook _book = null!;
|
|
private TotalsClassification _full = null!;
|
|
private List<ManualCost> _manual = [];
|
|
private List<CostCategory> _categories = [];
|
|
private Dictionary<int, IReadOnlyList<int>> _virtualSources = [];
|
|
|
|
private BucketPlan _plan = null!;
|
|
private AnalysisBucket _periodBucket = null!;
|
|
private IReadOnlyList<CostPart> _displayParts = [];
|
|
private IReadOnlyList<CostPart> _periodParts = [];
|
|
private Dictionary<int, IReadOnlyList<BucketValue>> _displayValues = [];
|
|
private Dictionary<int, IReadOnlyList<BucketValue>> _periodValues = [];
|
|
private Dictionary<int, IReadOnlyList<BucketValue>> _displaySpans = [];
|
|
private Dictionary<int, IReadOnlyList<BucketValue>> _periodSpans = [];
|
|
private Dictionary<int, AnalysisSeries> _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<CostAnalysis> 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);
|
|
}
|
|
|
|
/// <summary>What a scope has data for (D-19): its priced meters' coverage and its manual costs, without pricing.</summary>
|
|
public async Task<CostAvailability> 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
|
|
|
|
/// <summary>The scope as pricing groups, or null when its meter or category does not exist.</summary>
|
|
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<int> 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;
|
|
|
|
/// <summary>A type's bill (D-34, D-35): its lines, its own standing charge (D-40), the manual costs of its meters (D-41).</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A-18: the meter-scoped standing charges of a type's physical meters that no line of <paramref name="lines"/>
|
|
/// 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.
|
|
/// </summary>
|
|
private List<StandingChargeKey> MeterFeeRows(int type, IEnumerable<LineSpec> 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))];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<int> 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, []);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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),
|
|
};
|
|
}
|
|
|
|
/// <summary>Why a sum whose sources price nothing has no cost: all generation, all operating time, or no rule at all.</summary>
|
|
private MeterNotCostedReason NotCostedReasonOf(IReadOnlyList<int> 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<SeparatelyBilledMeter> SeparatelyBilledOf(int type) => _full.ForType(type).Billing.SeparatelyBilled;
|
|
|
|
/// <summary>
|
|
/// 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 <c>none</c> it is left out and reported.
|
|
/// </summary>
|
|
private IEnumerable<LineSpec> Expand(BillLine line, IReadOnlyList<SeparatelyBilledMeter> 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 [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// D-39 <c>sourceCosts</c>: 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 (<see cref="CostSourcesOf"/>). A separately billed subsection the
|
|
/// bill takes out of the sum is taken out of the source it runs through.
|
|
/// </summary>
|
|
private List<LineSpec> SourceLines(AnalysisMeter meter, IReadOnlyList<BillDeduction> deductions, IReadOnlyList<SeparatelyBilledMeter> separately)
|
|
{
|
|
var sources = CostSourcesOf(meter.Id) ?? [];
|
|
var perSource = sources.Select(_ => new List<BillDeduction>()).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<LineSpec>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The physical sources whose metered costs a <c>sourceCosts</c> meter adds (D-39): the meters its formula's weights
|
|
/// name — each once, so <c>m1 + m1 - m1 + m2</c> 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).
|
|
/// </summary>
|
|
private List<int>? CostSourcesOf(int virtualId)
|
|
{
|
|
var result = new List<int>();
|
|
var visiting = new HashSet<int>();
|
|
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<int> 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)
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<int>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="TypeGroup"/> adds them), and the global one.
|
|
/// </summary>
|
|
private List<StandingChargeKey> BillRows()
|
|
{
|
|
var rows = new List<StandingChargeKey>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>A category's members with type members expanded to every meter of the type, known meters only, ascending.</summary>
|
|
private IReadOnlyList<int> ExpandMembers(CostCategory category)
|
|
{
|
|
var ids = new SortedSet<int>();
|
|
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];
|
|
}
|
|
|
|
/// <summary>
|
|
/// The categories a manual cost belongs to (D-41): its own category, else every category holding its meter.
|
|
/// </summary>
|
|
private List<int> ClaimsOf(ManualCost cost, Dictionary<int, IReadOnlyList<int>> 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)]
|
|
: [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private bool HoldsRow(CostCategory category, StandingChargeKey row, IReadOnlyList<int> 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<CostCategoryOverlap> Overlaps(
|
|
CategoryOverlapReport report,
|
|
Dictionary<int, List<int>> manualClaims,
|
|
Dictionary<StandingChargeKey, List<int>> rowClaims)
|
|
{
|
|
var pairs = new SortedDictionary<(int, int), (SortedSet<int> Meters, SortedSet<int> Manual, List<StandingChargeKey> Rows)>();
|
|
|
|
(SortedSet<int> Meters, SortedSet<int> Manual, List<StandingChargeKey> 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<int> ids, Action<int, int> 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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<BucketPlan> 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),
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task ReadQuantitiesAsync(ScopePlan scope, CancellationToken cancellationToken)
|
|
{
|
|
var lines = scope.AllGroups().SelectMany(g => g.Lines).ToList();
|
|
List<int> 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<int> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>True when a meter's part is unresolved inside a bucket that has several parts (A-16).</summary>
|
|
private static bool NeedsSpans(IReadOnlyList<CostPart> parts, Dictionary<int, IReadOnlyList<BucketValue>> values, IReadOnlyList<int> 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));
|
|
}
|
|
|
|
/// <summary>The meters' values over whole buckets, one per bucket (A-16).</summary>
|
|
private async Task<Dictionary<int, IReadOnlyList<BucketValue>>> ReadSpansAsync(
|
|
IReadOnlyList<int> ids, IReadOnlyList<AnalysisBucket> buckets, BucketSize size, bool contributors, CancellationToken cancellationToken)
|
|
{
|
|
List<CostPart> 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<Dictionary<int, AnalysisSeries>> ReadPartsAsync(
|
|
IReadOnlyList<int> ids,
|
|
IReadOnlyList<CostPart> parts,
|
|
IReadOnlyList<AnalysisBucket> buckets,
|
|
BucketSize size,
|
|
bool contributors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
List<AnalysisBucket> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>Prices a group per bucket and over the period's own months.</summary>
|
|
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))));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private CostLine Line(
|
|
LineSpec spec,
|
|
IReadOnlyList<CostPart> parts,
|
|
Dictionary<int, IReadOnlyList<BucketValue>> values,
|
|
IReadOnlyList<AnalysisBucket> buckets,
|
|
Dictionary<int, IReadOnlyList<BucketValue>> spanValues,
|
|
Dictionary<int, SortedSet<DateOnly>> basisGaps)
|
|
{
|
|
var own = values.GetValueOrDefault(spec.MeterId);
|
|
var basis = BasisCheckOf(spec);
|
|
var quantities = new List<CostQuantity>(parts.Count);
|
|
var gapBuckets = new HashSet<int>();
|
|
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),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private List<CostSpan>? Spans(
|
|
LineSpec spec,
|
|
IReadOnlyList<CostPart> parts,
|
|
IReadOnlyList<AnalysisBucket> buckets,
|
|
Dictionary<int, IReadOnlyList<BucketValue>> spanValues,
|
|
HashSet<int> gapBuckets)
|
|
{
|
|
if (spanValues.GetValueOrDefault(spec.MeterId) is not { } own)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var spans = new List<CostSpan>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when some day of <paramref name="part"/> 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.
|
|
/// </summary>
|
|
private bool IsBasisGap(BasisCheck basis, CostPart part, int index, Dictionary<int, IReadOnlyList<BucketValue>> 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<DateOnly> Months(Dictionary<int, SortedSet<DateOnly>> map, int meterId)
|
|
{
|
|
if (!map.TryGetValue(meterId, out var months))
|
|
{
|
|
map[meterId] = months = [];
|
|
}
|
|
|
|
return months;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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>)[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<CompositionSlice>();
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private List<int> AfterToday(IEnumerable<PricingGroup> 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(),
|
|
];
|
|
}
|
|
|
|
/// <summary>The coverage of the scope's priced meters, from the series the quantities were read with.</summary>
|
|
private AvailableRange? MeteredAvailability(ScopePlan scope) =>
|
|
AvailableRange.Union(scope.AvailabilityMeters.Select(id => _series.GetValueOrDefault(id)?.Availability), _zone);
|
|
|
|
/// <summary>D-19: the priced meters' coverage and the manual costs' start days up to today, and the latest month of either.</summary>
|
|
private CostAvailability Availability(AvailableRange? metered, IReadOnlyList<ManualCost> 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);
|
|
}
|
|
|
|
/// <summary>Tariffs applied with an unchecked unit, each once with the first month it priced.</summary>
|
|
private static List<TariffWarning> Warnings(IEnumerable<PricingGroup> 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)];
|
|
|
|
/// <summary>The cost attention items (D-53) of the result.</summary>
|
|
private List<CostAttention> Attention(ScopePlan scope, CostAmount total, ManualCostFigure manual, IReadOnlyList<TariffWarning> warnings)
|
|
{
|
|
var items = new List<CostAttention>();
|
|
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<CategoryEntry> entries = scope.CategoryEntry is { } own
|
|
? [own]
|
|
: scope.Categories?.Entries.Values.OrderBy(e => e.Category.Sort).ThenBy(e => e.Category.Id) ?? Enumerable.Empty<CategoryEntry>();
|
|
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<int, SortedSet<DateOnly>> Merge(IEnumerable<Dictionary<int, SortedSet<DateOnly>>> maps)
|
|
{
|
|
var merged = new Dictionary<int, SortedSet<DateOnly>>();
|
|
foreach (var (meter, months) in maps.SelectMany(m => m))
|
|
{
|
|
Months(merged, meter).UnionWith(months);
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/// <summary>Whether a meter's billing problem belongs to the scope: its meter, its category's members, its type, or anything.</summary>
|
|
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
|
|
|
|
/// <summary>One priced line: whose quantity, in which unit, how, less what.</summary>
|
|
private sealed record LineSpec(
|
|
int MeterId,
|
|
int EnergyTypeId,
|
|
BillLineKind Kind,
|
|
string Unit,
|
|
IReadOnlyList<BillDeduction> Deductions,
|
|
int? ForMeterId);
|
|
|
|
/// <summary>What a physical meter's own scope prices, and how its cost is named.</summary>
|
|
private sealed record OwnCost(List<LineSpec> Lines, MeterCostRule Rule, bool OnBill, MeterNotCostedReason Reason);
|
|
|
|
/// <summary>The service windows of a type's billed grid meters (inclusive local days) and its use meters (A-17).</summary>
|
|
private sealed record BasisCheck(List<(DateOnly First, DateOnly Last)> GridService, IReadOnlyList<int> UseMeterIds);
|
|
|
|
/// <summary>Lines, standing-charge rows and manual costs priced together, and their priced figures.</summary>
|
|
private sealed class PricingGroup
|
|
{
|
|
public int? EnergyTypeId { get; init; }
|
|
|
|
public BillingBasis Basis { get; init; }
|
|
|
|
public List<LineSpec> Lines { get; } = [];
|
|
|
|
public List<StandingChargeKey> Rows { get; } = [];
|
|
|
|
public List<ManualCost> Manual { get; } = [];
|
|
|
|
public CostResult ByBucket { get; set; } = null!;
|
|
|
|
public CostResult OverPeriod { get; set; } = null!;
|
|
|
|
public List<CostLineFigure> Figures { get; } = [];
|
|
|
|
public List<StandingChargeFigure> RowFigures { get; } = [];
|
|
|
|
/// <summary>Per line meter: the months whose interval could not be priced because the price changes inside it (A-16).</summary>
|
|
public Dictionary<int, SortedSet<DateOnly>> PriceChanges { get; } = [];
|
|
|
|
/// <summary>Per grid meter: the months its type's grid basis missed while use was measured (A-17).</summary>
|
|
public Dictionary<int, SortedSet<DateOnly>> BasisGaps { get; } = [];
|
|
}
|
|
|
|
private sealed record CategoryEntry(CostCategory Category, CategoryCoverResult Cover, PricingGroup Group, bool IsView);
|
|
|
|
/// <summary>Every category's figure and the bill's composition by them.</summary>
|
|
private sealed class CategoryPlan
|
|
{
|
|
public Dictionary<int, CategoryEntry> Entries { get; } = [];
|
|
|
|
public IReadOnlyList<int> Disjoint { get; set; } = [];
|
|
|
|
public PricingGroup Uncategorized { get; } = new();
|
|
|
|
public IReadOnlyList<int> UncategorizedMeterIds { get; set; } = [];
|
|
|
|
public List<(StandingChargeKey Row, PricingGroup Group)> RowSlices { get; } = [];
|
|
|
|
public IReadOnlyList<CostCategoryOverlap> Overlaps { get; set; } = [];
|
|
|
|
/// <summary>The groups the composition prices (every category, Uncategorized, the rows of their own).</summary>
|
|
public IEnumerable<PricingGroup> Groups() =>
|
|
Entries.Values.Select(e => e.Group).Append(Uncategorized).Concat(RowSlices.Select(r => r.Group));
|
|
}
|
|
|
|
/// <summary>A scope as pricing groups: the groups its figure adds up, and whatever else it prices alongside.</summary>
|
|
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<int> AvailabilityMeters { get; init; } = [];
|
|
|
|
public IReadOnlyList<ManualCost> ManualInScope { get; init; } = [];
|
|
|
|
/// <summary>A physical meter's own page: outside its service period it has no data, as its quantity says.</summary>
|
|
public bool OwnSemantics { get; init; }
|
|
|
|
/// <summary>The groups whose sum is the scope's figure.</summary>
|
|
public IEnumerable<PricingGroup> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Everything the request prices: the scope's groups, and for the portfolio the composition's.</summary>
|
|
public IEnumerable<PricingGroup> AllGroups() =>
|
|
Single is null && Categories is not null ? Groups().Concat(Categories.Groups()) : Groups();
|
|
|
|
}
|
|
}
|