Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s
ci / build-test (push) Successful in 2m31s
The dashboards told several stories at once. Overview asked for full calendar years, meter detail for a fixed 12-month window that was really 13, Trends for 24 months with an Apply button, and the energy pages for 60. Each page derived "today" from UTC, so the first hours of a local day belonged to yesterday. A missing tariff, a month nobody measured and a genuine zero all rendered as 0. And a virtual meter -- the one thing the spreadsheet leans on hardest -- was excluded from analysis outright: MeterPeriodService returned null for it and the page offered a flow diagram instead. docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58 plus amendments A-01..A-30; code, tests and release notes cite those ids. The analysis layer Core/Analysis holds the pure rules: period presets resolved once in the instance zone into a local date range and a half-open UTC range, bucket plans, calendar-unit comparisons, coverage runs with a resolution class, normalized quantities and units, the totals policy, the virtual formula parser/validator/evaluator, and the cost calculator. "Now" comes from TimeProvider; services never read the clock. Normalization now writes, in the same transaction as consumption and by diff, per-meter rollups by local day and month plus coverage runs and a rollup state (AnalysisDataWriter). AnalysisReader answers a request from those tables -- month rollups for month and year buckets, day rollups otherwise, at most two partial edge days from consumption -- and CostReader prices the result month by month. Pages, /api/v1 and the CSV export read nothing else. The unused continuous aggregates are dropped. The reader's statement count per request is constant whether it covers one meter or a thousand. On a synthetic 1,000-meter, ten-year instance the brief's target request (100 meters, ten years, monthly) takes 374 ms against a two-second target, and the Overview went from 48,244 SQL statements per load to 205. Missing is not zero Every bucket carries a status -- available, partial, missing, unresolved, invalid, pending -- derived from coverage, never from the amount, with provenance and a reason code beside it. A true zero is a number and a bar on the baseline; an unknown bucket is a gap that says why; a month whose data only exists monthly says so instead of inventing daily detail; a scope with no tariff says "not priced" instead of 0. Rows whose interval closes after now are reported separately rather than counted. Virtual meters are analysis subjects A virtual meter stores a canonical definition -- expression over m<id> references, result kind, unit and cost rule -- validated on save and on read for syntax, unknown or self references, loops and unit/kind rules. It is evaluated on read from its sources' rollups over their joint coverage: a missing source makes the bucket missing, an observed zero is a valid input, a non-finite result is invalid with its dependency path, and the page lists each source's contribution. Topology links are topology only and never rewrite a saved calculation; expression-less meters from older installs are converted once at startup. The editor has Sum, Difference and Advanced modes with a live preview. Totals and the bill Per energy type the totals policy separates use, grid import, export, generation and runtime, marks breakdown meters as breakdowns and virtual meters as views, and never adds across units. The bill follows it: grid import where there is one, separately priced subsections at their own price, feed-in only on export meters, standing charges once per scope per local day, manual costs once on their start day, categories as non-overlapping covers whose composition reconciles to the bill. The seeded demo's yearly totals now match the spreadsheet. Pages and navigation The period lives in the URL and every page reads the same contract, so a link, a reload and the browser's Back button keep it. Shared components carry it: page header with breadcrumbs, period toolbar, theme-aware chart with an accessible table beside it, metric cards, comparison and availability states, attention items that each link to the one action that fixes them. Meter detail leads with an Analysis tab and resolves its tabs by key; the energy page has Overview, History, Flow and Meters; the old cost-only Trends page is a general Analysis page over portfolio, type, category, meter or a meter comparison. Records tabs are paged server-side instead of showing the latest 200. Everything is English and German, light and dark, down to 360px. Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and what the first start after the update does (it rebuilds all analysis data before the web server listens). docs/SDD.md and CLAUDE.md describe the system as it now is. Tests: 1,733 Core and 746 integration, all green, plus an opt-in performance suite with a synthetic 1,000-meter generator.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Totals;
|
||||
using MeterVault.Core.Analysis.Virtual;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Analysis;
|
||||
using MeterVault.Infrastructure.Costing;
|
||||
using static MeterVault.Integration.Tests.Costing.CostSandbox;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// The cost engine against real rollups (D-34 – D-41, brief §6.2, §11): missing versus free prices, gaps and unit
|
||||
/// mismatches, standing charges once per scope, prices month by month whatever the bucket size, separately billed
|
||||
/// subsections, virtual cost rules, feed-in credits, manual costs booked once, and the latest period with data.
|
||||
/// Every test builds its own energy types and meters (never the portfolio, which other tests share) and removes them.
|
||||
/// </summary>
|
||||
[Collection("Timescale")]
|
||||
public sealed class CostReaderTests(TimescaleFixture fx) : IAsyncLifetime
|
||||
{
|
||||
private CostSandbox _box = null!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_box = new CostSandbox(fx);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync() => await _box.DisposeAsync();
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_tariff_is_not_priced_and_a_zero_tariff_is_a_valid_zero()
|
||||
{
|
||||
// Brief §11 "Missing versus free tariff": a valid quantity without a required price has no cost; an explicit
|
||||
// zero tariff costs exactly zero.
|
||||
var (missingType, freeType) = (await _box.TypeAsync(), await _box.TypeAsync());
|
||||
var missing = await _box.MonthlyAsync(missingType, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
|
||||
var free = await _box.MonthlyAsync(freeType, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
|
||||
await _box.TypePriceAsync(freeType, 0, D(2026, 1, 1));
|
||||
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
|
||||
|
||||
var unpriced = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(missingType), period) { Bucket = BucketSize.Month });
|
||||
|
||||
Assert.Equal(CostStatus.NotPriced, unpriced.Total.Status);
|
||||
Assert.Null(unpriced.Total.Cost);
|
||||
Assert.True(unpriced.Total.IncludesNotPriced);
|
||||
Assert.Equal(
|
||||
new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, missingType, missing, D(2026, 1, 1), D(2026, 2, 1)),
|
||||
Assert.Single(unpriced.MissingPrices));
|
||||
var attention = Assert.Single(unpriced.Attention);
|
||||
Assert.Equal((CostAttentionKind.MissingPrice, (int?)missing), (attention.Kind, attention.MeterId));
|
||||
Assert.False(attention.Price!.IsCredit);
|
||||
|
||||
// The quantity is still there; only its price is missing.
|
||||
var line = Assert.Single(unpriced.Lines);
|
||||
Assert.Equal([100d, 80d], line.Quantities);
|
||||
Assert.Equal(180, line.TotalQuantity);
|
||||
Assert.Equal(BillingBasis.Use, Assert.Single(unpriced.EnergyTypes).Basis);
|
||||
|
||||
var priced = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(freeType), period) { Bucket = BucketSize.Month });
|
||||
|
||||
CostAssert.Priced(0, priced.Total);
|
||||
Assert.All(priced.Buckets, b => CostAssert.Priced(0, b));
|
||||
Assert.Empty(priced.MissingPrices);
|
||||
Assert.Equal(free, Assert.Single(priced.Lines).MeterId);
|
||||
Assert.Equal("EUR", priced.Currency);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_gap_in_a_price_history_makes_those_months_unavailable()
|
||||
{
|
||||
var type = await _box.TypeAsync();
|
||||
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100, 100);
|
||||
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1), to: D(2026, 1, 31));
|
||||
await _box.TypePriceAsync(type, 0.40, D(2026, 3, 1));
|
||||
|
||||
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 1, 1), D(2026, 4, 30))) { Bucket = BucketSize.Month });
|
||||
|
||||
Assert.Equal([CostStatus.Priced, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced], result.Buckets.Select(b => b.Status));
|
||||
Assert.Null(result.Buckets[1].Cost);
|
||||
CostAssert.Cost(30, result.Buckets[0]);
|
||||
Assert.Equal(CostStatus.Partial, result.Total.Status);
|
||||
CostAssert.Cost(110, result.Total);
|
||||
|
||||
var gap = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal((CostStatus.PriceGap, TariffScope.EnergyType, (int?)type, (int?)meter, D(2026, 2, 1), D(2026, 2, 1)),
|
||||
(gap.Reason, gap.Scope, gap.ScopeId, gap.MeterId, gap.FirstMonth, gap.LastMonth));
|
||||
Assert.Contains(result.Attention, a => a.Kind == CostAttentionKind.MissingPrice && a.Price == gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_price_in_another_unit_is_a_unit_mismatch_and_a_currency_follows_the_options()
|
||||
{
|
||||
var type = await _box.TypeAsync();
|
||||
await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100);
|
||||
var tariff = await _box.TypePriceAsync(type, 5, D(2026, 1, 1), unit: "EUR/m3");
|
||||
|
||||
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Month });
|
||||
|
||||
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
|
||||
Assert.Null(result.Total.Cost);
|
||||
var mismatch = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal((CostStatus.UnitMismatch, (int?)tariff), (mismatch.Reason, mismatch.TariffId));
|
||||
|
||||
// D-43: the instance currency is the options'; a "EUR" price does not fit a CHF instance.
|
||||
var swiss = await _box.Reader("CHF").ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Month });
|
||||
Assert.Equal("CHF", swiss.Currency);
|
||||
Assert.Equal(CostStatus.UnitMismatch, swiss.Total.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_standing_charge_accrues_once_per_scope_and_a_meter_fee_on_its_meter()
|
||||
{
|
||||
// Two consumption roots of one type: the type's standing charge is one row, never one per meter (D-40).
|
||||
var type = await _box.TypeAsync();
|
||||
var first = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
|
||||
var second = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
|
||||
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
|
||||
await _box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.BasePrice, 31, "EUR/month", D(2026, 1, 1));
|
||||
await _box.TariffAsync(TariffScope.Meter, second, TariffComponent.BasePrice, 5, "EUR/month", D(2026, 1, 1));
|
||||
var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31));
|
||||
|
||||
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), quarter) { Bucket = BucketSize.Month });
|
||||
|
||||
var row = Assert.Single(bill.StandingCharges);
|
||||
Assert.Equal((TariffScope.EnergyType, (int?)type), (row.Scope, row.ScopeId));
|
||||
CostAssert.Priced(93, row.Total);
|
||||
Assert.Equal(D(2026, 1, 1), row.Service!.FirstDay);
|
||||
Assert.All(row.Buckets, b => CostAssert.Priced(31, b));
|
||||
|
||||
// 600 kWh × 0.10 + 3 × 31 € + the second meter's own fee 3 × 5 €, on its line.
|
||||
CostAssert.Priced(60 + 93 + 15, bill.Total);
|
||||
Assert.Null(bill.Lines.Single(l => l.MeterId == first).Total.StandingCharge);
|
||||
Assert.Equal(15, bill.Lines.Single(l => l.MeterId == second).Total.StandingCharge!.Value, 6);
|
||||
|
||||
// A meter's own cost carries its own fee, never the type's charge.
|
||||
var own = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(second), quarter) { Bucket = BucketSize.Month });
|
||||
Assert.Empty(own.StandingCharges);
|
||||
CostAssert.Priced(30 + 15, own.Total);
|
||||
Assert.Equal(MeterCostRule.BillLine, own.Meter!.Rule);
|
||||
|
||||
// A standing charge accrues per day: half of February is half of February's charge.
|
||||
var half = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 2, 1), D(2026, 2, 14))) { Bucket = BucketSize.Day });
|
||||
CostAssert.Cost(31 * 14 / 28d, Assert.Single(half.StandingCharges).Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_bucket_size_never_changes_a_total_and_every_part_is_priced_in_its_month()
|
||||
{
|
||||
// D-36: a year with a price change on 1 July, at daily resolution. Year, month, week and day buckets all
|
||||
// price January to June at 0.30 and July to December at 0.40 — a week across the change is split.
|
||||
var type = await _box.TypeAsync();
|
||||
await _box.DailyAsync(type, D(2025, 1, 1), D(2026, 1, 1), perDay: 10);
|
||||
await _box.TypePriceAsync(type, 0.30, D(2025, 1, 1), to: D(2025, 6, 30));
|
||||
await _box.TypePriceAsync(type, 0.40, D(2025, 7, 1));
|
||||
const double expected = (181 * 10 * 0.30) + (184 * 10 * 0.40);
|
||||
|
||||
foreach (var (size, count) in new[] { (BucketSize.Year, 1), (BucketSize.Month, 12), (BucketSize.Week, 53), (BucketSize.Day, 365) })
|
||||
{
|
||||
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Year(2025)) { Bucket = size });
|
||||
|
||||
Assert.Equal(count, result.Buckets.Count);
|
||||
CostAssert.Priced(expected, result.Total, 1e-6);
|
||||
Assert.Equal(expected, result.Buckets.Sum(b => b.Cost!.Value), 6);
|
||||
Assert.All(result.Buckets, b => Assert.Equal(BucketStatus.Available, b.Availability));
|
||||
if (size == BucketSize.Week)
|
||||
{
|
||||
// Monday 30 June – Sunday 6 July: one day at June's price, six at July's.
|
||||
var index = result.Plan.Buckets.ToList().FindIndex(b => b.FirstDay == D(2025, 6, 30));
|
||||
CostAssert.Cost((10 * 0.30) + (60 * 0.40), result.Buckets[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_monthly_import_prices_its_month_although_its_days_are_unresolved()
|
||||
{
|
||||
var type = await _box.TypeAsync();
|
||||
await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
|
||||
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
|
||||
|
||||
var daily = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Day });
|
||||
|
||||
Assert.Equal(31, daily.Buckets.Count);
|
||||
Assert.All(daily.Buckets, b =>
|
||||
{
|
||||
Assert.Null(b.Cost);
|
||||
Assert.Equal(BucketStatus.Unresolved, b.Availability);
|
||||
});
|
||||
CostAssert.Priced(10, daily.Total);
|
||||
Assert.Equal(BucketStatus.Available, daily.Total.Availability);
|
||||
|
||||
// Auto chooses a size the data resolves, so the chart has values.
|
||||
var auto = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 1, 1), D(2026, 2, 28))));
|
||||
Assert.Equal(BucketSize.Month, auto.Plan.Size);
|
||||
Assert.Equal([10d, 8d], auto.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_retired_meter_is_a_known_zero_on_the_bill_and_missing_on_its_own_page()
|
||||
{
|
||||
// D-24: outside its service period a meter contributes a known zero to the bill, so a meter replaced at the end
|
||||
// of January leaves the quarter's bill complete; its own page still shows no data after it was retired.
|
||||
var type = await _box.TypeAsync();
|
||||
var retired = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), retiredAt: D(2026, 1, 31));
|
||||
await _box.MonthlyReadingsAsync(retired, D(2026, 1, 1), 100);
|
||||
var successor = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1));
|
||||
await _box.MonthlyReadingsAsync(successor, D(2026, 2, 1), 80, 90);
|
||||
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
|
||||
var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31));
|
||||
|
||||
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), quarter) { Bucket = BucketSize.Month });
|
||||
|
||||
Assert.Equal([10d, 8d, 9d], bill.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
|
||||
Assert.All(bill.Buckets, b => Assert.Equal(BucketStatus.Available, b.Availability));
|
||||
CostAssert.Priced(27, bill.Total);
|
||||
Assert.Equal(BucketStatus.Available, bill.Total.Availability);
|
||||
|
||||
var own = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(retired), quarter) { Bucket = BucketSize.Month });
|
||||
CostAssert.Priced(10, own.Total);
|
||||
Assert.Equal(BucketStatus.Partial, own.Total.Availability);
|
||||
Assert.Equal(BucketStatus.Missing, own.Buckets[1].Availability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unpriced_line_does_not_coarsen_the_automatic_buckets()
|
||||
{
|
||||
// A delta meter reporting once a quarter resolves only years; it has no tariff, so it has no cost to chart and
|
||||
// must not turn the priced monthly meter's chart into one yearly bar.
|
||||
var type = await _box.TypeAsync();
|
||||
var monthly = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100, 100, 100, 100);
|
||||
await _box.MeterPriceAsync(monthly, 0.10, D(2026, 1, 1));
|
||||
var quarterly = await _box.MeterAsync(type, MeterMode.DirectDelta, "kWh", D(2026, 1, 1));
|
||||
await _box.ReadingsAsync(quarterly, (Midnight(2026, 1, 1), 0), (Midnight(2026, 4, 1), 300), (Midnight(2026, 7, 1), 300));
|
||||
var half = Custom(D(2026, 1, 1), D(2026, 6, 30));
|
||||
|
||||
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), half));
|
||||
|
||||
Assert.Equal(BucketSize.Month, result.Plan.Size);
|
||||
Assert.Equal(6, result.Buckets.Count);
|
||||
Assert.All(result.Buckets, b => Assert.Equal(10, b.Cost!.Value, 6));
|
||||
Assert.Contains(result.MissingPrices, m => m.MeterId == quarterly && m.Reason == CostStatus.NotPriced);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_separately_billed_subsection_is_priced_at_its_own_price_out_of_its_parent()
|
||||
{
|
||||
// D-35: house 300 kWh a month, a heat pump below it 100 kWh at its own 0.22 — billed (300 − 100) × 0.30 + 100 × 0.22.
|
||||
var type = await _box.TypeAsync();
|
||||
var house = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 300, 300);
|
||||
var pump = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100);
|
||||
await _box.LinkAsync(house, pump);
|
||||
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
|
||||
await _box.MeterPriceAsync(pump, 0.22, D(2026, 1, 1));
|
||||
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
|
||||
|
||||
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
|
||||
|
||||
CostAssert.Priced((400 * 0.30) + (200 * 0.22), bill.Total);
|
||||
var houseLine = bill.Lines.Single(l => l.MeterId == house);
|
||||
Assert.Equal(BillLineKind.UnitPrice, houseLine.Kind);
|
||||
Assert.Equal([200d, 200d], houseLine.Quantities);
|
||||
Assert.Equal(pump, Assert.Single(houseLine.Deductions).MeterId);
|
||||
var pumpLine = bill.Lines.Single(l => l.MeterId == pump);
|
||||
Assert.Equal(BillLineKind.OwnPrice, pumpLine.Kind);
|
||||
CostAssert.Priced(44, pumpLine.Total);
|
||||
|
||||
var pumpCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(pump), period) { Bucket = BucketSize.Month });
|
||||
Assert.Equal((MeterCostRule.BillLine, true), (pumpCost.Meter!.Rule, pumpCost.Meter.OnBill));
|
||||
CostAssert.Priced(44, pumpCost.Total);
|
||||
var houseCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(house), period) { Bucket = BucketSize.Month });
|
||||
CostAssert.Priced(120, houseCost.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Virtual_meters_are_costed_by_their_named_rule()
|
||||
{
|
||||
// D-39: a pure sum adds its sources' costs at their own prices; a linear formula with ownQuantity prices its
|
||||
// quantity; a difference has no cost. The two rules differ when the sources are priced differently.
|
||||
var type = await _box.TypeAsync();
|
||||
var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
|
||||
var b = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 150, 120);
|
||||
var generator = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 40, 50);
|
||||
await _box.TypePriceAsync(type, 0.25, D(2026, 1, 1));
|
||||
await _box.MeterPriceAsync(a, 0.20, D(2026, 1, 1));
|
||||
await _box.MeterPriceAsync(b, 0.30, D(2026, 1, 1));
|
||||
var sources = await _box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||||
var own = await _box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
|
||||
var difference = await _box.VirtualAsync(type, $"m{a} - m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||||
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
|
||||
|
||||
async Task<CostAnalysis> CostOf(int meter) =>
|
||||
await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter), period) { Bucket = BucketSize.Month });
|
||||
|
||||
var summed = await CostOf(sources);
|
||||
Assert.Equal(MeterCostRule.SourceCosts, summed.Meter!.Rule);
|
||||
Assert.False(summed.Meter.OnBill);
|
||||
Assert.Equal([a, b], summed.Meter.SourceIds);
|
||||
CostAssert.Priced((180 * 0.20) + (270 * 0.30), summed.Total);
|
||||
Assert.All(summed.Lines, l => Assert.Equal(sources, l.ForMeterId));
|
||||
|
||||
var repriced = await CostOf(own);
|
||||
Assert.Equal(MeterCostRule.OwnQuantity, repriced.Meter!.Rule);
|
||||
CostAssert.Priced(450 * 0.25, repriced.Total);
|
||||
Assert.Equal(own, Assert.Single(repriced.Lines).MeterId);
|
||||
|
||||
var none = await CostOf(difference);
|
||||
Assert.Equal((MeterCostRule.None, MeterNotCostedReason.NoCostRule), (none.Meter!.Rule, none.Meter.NotCosted));
|
||||
Assert.Empty(none.Lines);
|
||||
Assert.Null(none.Total.Cost);
|
||||
|
||||
var generation = await CostOf(generator);
|
||||
Assert.Equal((MeterCostRule.None, MeterNotCostedReason.Generation), (generation.Meter!.Rule, generation.Meter.NotCosted));
|
||||
|
||||
// The sources are the type's bill; the virtual views never add to it.
|
||||
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
|
||||
Assert.Equal([a, b], bill.Lines.Select(l => l.MeterId).Order());
|
||||
CostAssert.Priced((180 * 0.20) + (270 * 0.30), bill.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_virtual_meter_counted_by_an_override_is_billed_by_its_cost_rule()
|
||||
{
|
||||
// D-23 + D-39: "always" puts a virtual pure sum on the bill in place of its sources; the bill then prices it by
|
||||
// its rule — its sources at their own prices, or its own quantity — and never both. With the rule "none" it is
|
||||
// left out and named.
|
||||
const string always = "{\"totals\":\"always\"}";
|
||||
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
|
||||
|
||||
async Task<(int A, int B, short Type)> SourcesAsync()
|
||||
{
|
||||
var type = await _box.TypeAsync();
|
||||
var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
|
||||
var b = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 150, 120);
|
||||
await _box.TypePriceAsync(type, 0.25, D(2026, 1, 1));
|
||||
await _box.MeterPriceAsync(a, 0.20, D(2026, 1, 1));
|
||||
await _box.MeterPriceAsync(b, 0.30, D(2026, 1, 1));
|
||||
return (a, b, type);
|
||||
}
|
||||
|
||||
async Task<CostAnalysis> BillOf(short type) =>
|
||||
await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
|
||||
|
||||
var (a1, b1, bySources) = await SourcesAsync();
|
||||
var summed = await _box.VirtualAsync(bySources, $"m{a1} + m{b1}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts, always);
|
||||
var sourcesBill = await BillOf(bySources);
|
||||
Assert.Equal([a1, b1], sourcesBill.Lines.Select(l => l.MeterId).Order());
|
||||
Assert.All(sourcesBill.Lines, l => Assert.Equal(summed, l.ForMeterId));
|
||||
CostAssert.Priced((180 * 0.20) + (270 * 0.30), sourcesBill.Total);
|
||||
var summedCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(summed), period) { Bucket = BucketSize.Month });
|
||||
Assert.Equal((MeterCostRule.SourceCosts, true), (summedCost.Meter!.Rule, summedCost.Meter.OnBill));
|
||||
|
||||
var (a2, b2, byQuantity) = await SourcesAsync();
|
||||
var own = await _box.VirtualAsync(byQuantity, $"m{a2} + m{b2}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity, always);
|
||||
var ownBill = await BillOf(byQuantity);
|
||||
Assert.Equal(own, Assert.Single(ownBill.Lines).MeterId);
|
||||
CostAssert.Priced(450 * 0.25, ownBill.Total);
|
||||
|
||||
var (a3, b3, uncosted) = await SourcesAsync();
|
||||
var none = await _box.VirtualAsync(uncosted, $"m{a3} + m{b3}", QuantityKind.Consumption, "kWh", VirtualCostRule.None, always);
|
||||
var noneBill = await BillOf(uncosted);
|
||||
Assert.Empty(noneBill.Lines);
|
||||
Assert.Null(noneBill.Total.Cost);
|
||||
Assert.Equal(none, Assert.Single(noneBill.Attention, x => x.Kind == CostAttentionKind.VirtualNotCosted).MeterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_is_credited_at_the_feed_in_price_and_a_missing_one_is_an_optional_credit()
|
||||
{
|
||||
var type = await _box.TypeAsync();
|
||||
var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
|
||||
var export = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport));
|
||||
await _box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 300, 300);
|
||||
await _box.MonthlyReadingsAsync(export, D(2026, 1, 1), 50, 50);
|
||||
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
|
||||
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
|
||||
|
||||
var noCredit = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
|
||||
CostAssert.Priced(180, noCredit.Total);
|
||||
var credit = Assert.Single(noCredit.MissingPrices);
|
||||
Assert.True(credit.IsCredit);
|
||||
Assert.Equal((CostStatus.NotPriced, (int?)export), (credit.Reason, credit.MeterId));
|
||||
|
||||
await _box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2026, 1, 1));
|
||||
var credited = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
|
||||
|
||||
CostAssert.Priced(180 - 8, credited.Total);
|
||||
Assert.Equal(180, credited.Total.Charges!.Value, 6);
|
||||
Assert.Equal(8, credited.Total.FeedInCredit!.Value, 6);
|
||||
Assert.Equal(BillLineKind.FeedIn, credited.Lines.Single(l => l.MeterId == export).Kind);
|
||||
Assert.Empty(credited.MissingPrices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Manual_costs_are_booked_once_on_their_start_day_wherever_they_belong()
|
||||
{
|
||||
// D-41: a manual cost on a meter belongs to that meter, its type and its categories — once each — and one
|
||||
// dated after today is reported, not booked.
|
||||
var type = await _box.TypeAsync();
|
||||
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
|
||||
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
|
||||
var booked = await _box.ManualCostAsync(D(2026, 2, 10), 50, meterId: meter);
|
||||
var later = await _box.ManualCostAsync(D(2026, 10, 1), 20, meterId: meter);
|
||||
var category = await _box.CategoryAsync($"cost-{Guid.NewGuid():N}", 90, meters: [meter]);
|
||||
var year = Year(2026);
|
||||
|
||||
foreach (var scope in new[] { CostScope.ForEnergyType(type), CostScope.ForMeter(meter), CostScope.ForCategory(category) })
|
||||
{
|
||||
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(scope, year) { Bucket = BucketSize.Month });
|
||||
|
||||
CostAssert.Priced(30 + 50, result.Total);
|
||||
var booking = Assert.Single(result.ManualCosts.Bookings);
|
||||
Assert.Equal((booked, D(2026, 2, 10), 1, 50d), (booking.ManualCostId, booking.Day, booking.BucketIndex, booking.Amount));
|
||||
CostAssert.Priced(50, result.ManualCosts.Buckets[1]);
|
||||
Assert.Equal([later], result.ManualCosts.AfterTodayIds);
|
||||
Assert.Equal([later], Assert.Single(result.Attention, a => a.Kind == CostAttentionKind.ManualCostAfterToday).ManualCostIds);
|
||||
}
|
||||
|
||||
var figure = (await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(category), year) { Bucket = BucketSize.Month })).Category!;
|
||||
Assert.Contains(booked, figure.ManualCostIds);
|
||||
Assert.Equal([meter], figure.Cover.BilledMeterIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_latest_period_with_data_includes_manual_costs()
|
||||
{
|
||||
// D-19: metered data ends in February; a manual cost on the meter in April makes April the latest month.
|
||||
var type = await _box.TypeAsync();
|
||||
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
|
||||
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
|
||||
|
||||
var metered = await _box.Reader().GetAvailabilityAsync(CostScope.ForEnergyType(type), Now);
|
||||
Assert.Equal(new LatestPeriod(D(2026, 2, 1), LatestPeriodBasis.Meters), metered.Latest);
|
||||
|
||||
await _box.ManualCostAsync(D(2026, 4, 10), 12, meterId: meter);
|
||||
var withManual = await _box.Reader().GetAvailabilityAsync(CostScope.ForEnergyType(type), Now);
|
||||
Assert.Equal(new LatestPeriod(D(2026, 4, 1), LatestPeriodBasis.Manual), withManual.Latest);
|
||||
Assert.Equal((D(2026, 1, 1), D(2026, 4, 10)), (withManual.Range!.FirstDay, withManual.Range.LastDay));
|
||||
|
||||
// The priced result reports the same, whatever period it prices.
|
||||
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter), Preset(PeriodPreset.MonthToDate)));
|
||||
Assert.Equal(withManual.Latest, result.Availability.Latest);
|
||||
|
||||
// A month with both is both.
|
||||
await _box.ManualCostAsync(D(2026, 2, 3), 5, meterId: meter);
|
||||
var both = await _box.Reader().GetAvailabilityAsync(CostScope.ForMeter(meter), new DateTimeOffset(2026, 3, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
Assert.Equal(new LatestPeriod(D(2026, 2, 1), LatestPeriodBasis.Both), both.Latest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_scopes_and_too_many_points_are_refused_before_pricing()
|
||||
{
|
||||
var reader = _box.Reader();
|
||||
|
||||
Assert.Equal(CostRefusal.UnknownScope, (await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(-1), Month(2026, 1)))).Refusal);
|
||||
Assert.Equal(CostRefusal.UnknownScope, (await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(-1), Month(2026, 1)))).Refusal);
|
||||
|
||||
var days = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Preset(PeriodPreset.Last24Months)) { Bucket = BucketSize.Day });
|
||||
Assert.Equal(CostRefusal.TooManyPoints, days.Refusal);
|
||||
Assert.Equal(BucketSize.Week, days.Plan.Suggested);
|
||||
Assert.Null(days.Total.Cost);
|
||||
|
||||
var other = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, TimeZoneInfo.Utc);
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, other)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user