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,167 @@
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Totals;
|
||||
using MeterVault.Core.Domain;
|
||||
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
|
||||
|
||||
namespace MeterVault.Core.Tests.Analysis.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// Cost figures fold exactly: the sum of the parts is the same figure — value, status and missing prices — however it
|
||||
/// is grouped. And the calculator refuses input it cannot price honestly.
|
||||
/// </summary>
|
||||
public sealed class CostAmountTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_empty_figure_is_priced_with_no_value()
|
||||
{
|
||||
Assert.Equal(CostStatus.Priced, CostAmount.Empty.Status);
|
||||
Assert.Null(CostAmount.Empty.Cost);
|
||||
Assert.Equal(BucketStatus.Available, CostAmount.Empty.Availability);
|
||||
Assert.Empty(CostAmount.Empty.MissingPrices);
|
||||
|
||||
var sum = CostAmount.Sum([CostAmount.Empty, CostAmount.Empty]);
|
||||
Assert.Equal((CostStatus.Priced, (double?)null, false), (sum.Status, sum.Cost, sum.IncludesNotPriced));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Summing_is_associative_over_lines_and_buckets()
|
||||
{
|
||||
// A priced grid line, a water line with a gap, an unpriced tank, a standing charge and a manual cost: the bill
|
||||
// total must be the same whether the buckets or the lines are added first.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
TypePrice(2, Water, 5, "EUR/m3", D(2025, 3, 1)),
|
||||
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30));
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 50), "L")],
|
||||
standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))],
|
||||
manual: [Manual(1, D(2025, 4, 2), 99)]);
|
||||
|
||||
var byLine = CostAmount.Sum(result.Lines.Select(l => l.Total).Concat(result.StandingCharges.Select(r => r.Total)).Append(result.ManualCosts.Total));
|
||||
var byBucket = CostAmount.Sum(result.Totals);
|
||||
|
||||
foreach (var figure in new[] { byLine, byBucket })
|
||||
{
|
||||
Assert.Equal(result.Total.Cost!.Value, figure.Cost!.Value, 9);
|
||||
Assert.Equal(result.Total.Status, figure.Status);
|
||||
Assert.Equal(result.Total.IncludesNotPriced, figure.IncludesNotPriced);
|
||||
Assert.Equal(result.Total.MissingPrices, figure.MissingPrices);
|
||||
}
|
||||
|
||||
Assert.Equal(CostStatus.Partial, result.Total.Status);
|
||||
Assert.Equal((6 * 30) + (4 * 50) + (6 * 12) + 99, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_prices_merge_into_one_entry_with_their_first_and_last_month()
|
||||
{
|
||||
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1)));
|
||||
var buckets = Buckets(BucketSize.Day, D(2025, 3, 30), D(2025, 5, 2));
|
||||
|
||||
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 1), "m³")]);
|
||||
|
||||
var missing = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal((D(2025, 3, 1), D(2025, 5, 1)), (missing.FirstMonth, missing.LastMonth));
|
||||
|
||||
// Each day bucket names its own month.
|
||||
Assert.Equal(D(2025, 4, 1), Assert.Single(result.Totals.At(buckets, D(2025, 4, 17)).MissingPrices).FirstMonth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_unit_mismatch_outranks_a_gap_when_nothing_is_priced()
|
||||
{
|
||||
var book = Book(Tariff(1, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.3, "EUR/kWh", D(2025, 2, 1)));
|
||||
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]);
|
||||
|
||||
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
|
||||
Assert.Equal([CostStatus.PriceGap, CostStatus.UnitMismatch], result.MissingPrices.Select(m => m.Reason));
|
||||
Assert.Null(result.Total.Cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Availability_reports_the_most_telling_reason_when_nothing_is_known()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 3, 31));
|
||||
var parts = CostCalculator.Parts(buckets);
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, [
|
||||
CostQuantity.Unknown(parts[0]),
|
||||
CostQuantity.Unknown(parts[1], BucketStatus.Unresolved),
|
||||
CostQuantity.Unknown(parts[2], BucketStatus.Pending)])]);
|
||||
|
||||
Assert.Equal(BucketStatus.Pending, result.Total.Availability);
|
||||
Assert.Null(result.Total.Cost);
|
||||
}
|
||||
|
||||
// ---- refusing malformed input ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void A_quantity_that_is_not_a_part_of_the_request_is_refused()
|
||||
{
|
||||
var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2));
|
||||
|
||||
// The whole week in one quantity would be priced at one month's price: the caller must split it.
|
||||
var wholeWeek = new CostQuantity(D(2025, 1, 27), D(2025, 2, 3), 70, BucketStatus.Available);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [wholeWeek])]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_quantity_given_twice_or_not_finite_is_refused()
|
||||
{
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
|
||||
var part = CostCalculator.Parts(buckets).Single();
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, 1), CostQuantity.Known(part, 2)])]));
|
||||
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, double.NaN)])]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overlapping_buckets_are_refused()
|
||||
{
|
||||
var buckets = new[] { Bucket(D(2025, 1, 1), D(2025, 2, 1)), Bucket(D(2025, 1, 15), D(2025, 1, 16), BucketSize.Day) };
|
||||
|
||||
Assert.Throws<ArgumentException>(() => CostCalculator.Parts(buckets));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_standing_charge_row_names_its_scope_and_a_meter_with_a_line_is_not_a_row()
|
||||
{
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
|
||||
|
||||
// A-18: a meter's own row is for a meter without a line; with one, its charge accrues on the line.
|
||||
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.Meter, null, null)]));
|
||||
Assert.Throws<ArgumentException>(() => Price(
|
||||
buckets, Book(), [Line(Grid, Electricity, Each(buckets, 1))], standing: [StandingChargeScope.ForMeter(Grid, null)]));
|
||||
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.EnergyType, null, null)]));
|
||||
Assert.Throws<ArgumentException>(() => Price(
|
||||
buckets, Book(), [], standing: [StandingChargeScope.Global(null), StandingChargeScope.Global(null)]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_service_period_cannot_end_before_it_starts()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new ServicePeriod(D(2025, 2, 1), D(2025, 1, 31)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_request_prices_nothing()
|
||||
{
|
||||
var result = Price([], Book(), []);
|
||||
|
||||
Assert.Empty(result.Totals);
|
||||
Assert.Null(result.Total.Cost);
|
||||
Assert.Equal("EUR", result.Currency);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Totals;
|
||||
using MeterVault.Core.Costing;
|
||||
using MeterVault.Core.Domain;
|
||||
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
|
||||
using Seed = MeterVault.Core.Tests.Analysis.TotalsSeed;
|
||||
|
||||
namespace MeterVault.Core.Tests.Analysis.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// The calculator prices what <see cref="TotalsPolicy"/> bills (D-34/D-35): on the reference data that is Netz × price,
|
||||
/// the spreadsheet's <c>Kosten</c>; a separately billed heat pump at its own price; and virtual meters by their named
|
||||
/// cost rule (D-39).
|
||||
/// </summary>
|
||||
public sealed class CostCalculatorBillTests
|
||||
{
|
||||
/// <summary>The seeded electricity price history (ReferenceDataImporter).</summary>
|
||||
private static readonly Tariff[] StromPrices =
|
||||
[
|
||||
TypePrice(1, Seed.Electricity, 0.16, "EUR/kWh", D(2022, 9, 1)),
|
||||
TypePrice(2, Seed.Electricity, 0.44, "EUR/kWh", D(2023, 1, 1)),
|
||||
TypePrice(3, Seed.Electricity, 0.37, "EUR/kWh", D(2023, 5, 1)),
|
||||
TypePrice(4, Seed.Electricity, 0.27, "EUR/kWh", D(2023, 11, 1)),
|
||||
TypePrice(5, Seed.Electricity, 0.36, "EUR/kWh", D(2025, 1, 1)),
|
||||
TypePrice(6, Seed.Electricity, 0.27, "EUR/kWh", D(2026, 1, 1)),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void The_seeded_Strom_bill_prices_the_grid_import_as_the_sheet_does()
|
||||
{
|
||||
// Kosten = Verbrauchskosten − Ersparnis = Netz × €/kWh: Oct 2022 416 × 0.16, Jan 2023 1170 × 0.44, May 2026 827 × 0.27.
|
||||
var classification = TotalsPolicy.Classify(Seed.Meters(), Seed.Links());
|
||||
var bill = classification.ForType(Seed.Electricity).Billing;
|
||||
var netz = new Dictionary<DateOnly, double> { [D(2022, 10, 1)] = 416, [D(2023, 1, 1)] = 1170, [D(2026, 5, 1)] = 827 };
|
||||
var book = Book(StromPrices);
|
||||
|
||||
foreach (var (month, amount) in netz)
|
||||
{
|
||||
var buckets = Buckets(BucketSize.Month, month, month.AddMonths(1).AddDays(-1));
|
||||
var lines = bill.Lines
|
||||
.Select(l => new CostLine(l.MeterId, Seed.Electricity, l.Kind, "kWh", l.MeterId == Seed.Netz ? Each(buckets, amount) : Each(buckets, 9999)))
|
||||
.ToList();
|
||||
|
||||
var result = CostCalculator.Calculate(new CostRequest(buckets, D(2026, 9, 19), book, lines));
|
||||
|
||||
Assert.Equal([Seed.Netz], result.Lines.Select(l => l.MeterId));
|
||||
Assert.Equal(amount * SheetPrice(month), result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
static double SheetPrice(DateOnly month) =>
|
||||
TariffResolver.ResolveValue(StromPrices, TariffComponent.UnitPrice, Seed.Netz, Seed.Electricity, TariffBook.PriceDate(month));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_separately_billed_heat_pump_is_priced_at_its_own_price_out_of_the_grid_import()
|
||||
{
|
||||
// SeparatelyBilledSubmeterTests: main 300 kWh at 0.30 and heat pump 100 kWh at 0.22 bill 200 × 0.30 + 100 × 0.22.
|
||||
const int heatPump = 10;
|
||||
var meters = Seed.Meters();
|
||||
meters.Add(Seed.Physical(heatPump, "Wärmepumpe", Seed.Electricity, MeterMode.CumulativeCounter, "kWh"));
|
||||
var book = Book(TypePrice(1, Seed.Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), MeterPrice(2, heatPump, 0.22, "EUR/kWh", D(2025, 1, 1)));
|
||||
var classification = TotalsPolicy.Classify(meters, [.. Seed.Links(), new(Seed.Haus, heatPump)], book.HasMeterScopedUnitPrice);
|
||||
var bill = classification.ForType(Seed.Electricity).Billing;
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
var gross = new Dictionary<int, double> { [Seed.Netz] = 300, [heatPump] = 100 };
|
||||
|
||||
// The engine's part: net quantities, the deductions taken out of the line they belong to.
|
||||
var lines = bill.Lines
|
||||
.Select(l => new CostLine(
|
||||
l.MeterId,
|
||||
Seed.Electricity,
|
||||
l.Kind,
|
||||
"kWh",
|
||||
Each(buckets, gross[l.MeterId] - l.Deductions.Sum(d => gross[d.MeterId] * d.UnitFactor))))
|
||||
.ToList();
|
||||
|
||||
var result = CostCalculator.Calculate(new CostRequest(buckets, D(2026, 1, 1), book, lines));
|
||||
|
||||
Assert.Equal([(Seed.Netz, BillLineKind.UnitPrice), (heatPump, BillLineKind.OwnPrice)], result.Lines.Select(l => (l.MeterId, l.Kind)));
|
||||
Assert.Equal(60, result.Lines[0].Total.Cost!.Value, 9);
|
||||
Assert.Equal(22, result.Lines[1].Total.Cost!.Value, 9);
|
||||
Assert.Equal(82, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_its_own_price_starts_a_subsection_is_billed_inside_its_parent()
|
||||
{
|
||||
// The heat pump's own tariff starts in March. In January and February it is not billed separately: the engine
|
||||
// leaves its energy in the grid import (HasOwnUnitPrice), and its own line has nothing — no gap, no zero.
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), MeterPrice(2, Heater, 0.22, "EUR/kWh", D(2025, 3, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
var grid = Monthly(buckets, m => book.HasOwnUnitPrice(Heater, m) ? 300 - 100 : 300);
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, grid), Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice)]);
|
||||
|
||||
var heater = result.Lines[1];
|
||||
Assert.Equal([D(2025, 1, 1), D(2025, 2, 1)], heater.MonthsWithoutOwnPrice);
|
||||
Assert.Null(heater.Buckets[0].Cost);
|
||||
Assert.Equal(CostStatus.Priced, heater.Buckets[0].Status);
|
||||
Assert.Equal(22, heater.Buckets[2].Cost!.Value, 9);
|
||||
Assert.Empty(result.MissingPrices);
|
||||
Assert.Equal([90d, 90d, 60d + 22d], result.Totals.Select(t => Math.Round(t.Cost!.Value, 9)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_own_price_line_never_takes_the_type_price()
|
||||
{
|
||||
// D-35's OwnPrice means the meter's own price: a line for a meter without one is not priced, not priced at the type's.
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice)]);
|
||||
|
||||
Assert.Equal(CostStatus.NotPriced, result.Total.Status);
|
||||
Assert.Equal((TariffScope.Meter, (int?)Heater), (result.MissingPrices[0].Scope, result.MissingPrices[0].ScopeId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Source_costs_and_own_quantity_differ_when_the_sources_have_different_prices()
|
||||
{
|
||||
// D-39: a virtual sum of the house (type price 0.30) and a heat pump (own price 0.22), 100 kWh each. Summing the
|
||||
// sources' costs gives 52; pricing the sum as its own quantity takes the type price: 60. The rule is named.
|
||||
const int sum = 40;
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
MeterPrice(2, Heater, 0.22, "EUR/kWh", D(2025, 1, 1)),
|
||||
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
|
||||
// sourceCosts: each source at its own precedence, without the type's standing charge.
|
||||
var sourceCosts = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100)), Line(Heater, Electricity, Each(buckets, 100))]);
|
||||
|
||||
// ownQuantity: the virtual meter's evaluated quantity as one line.
|
||||
var ownQuantity = Price(buckets, book, [Line(sum, Electricity, Each(buckets, 200))]);
|
||||
|
||||
Assert.Equal(52, sourceCosts.Total.Cost!.Value, 9);
|
||||
Assert.Empty(sourceCosts.StandingCharges);
|
||||
Assert.Equal(60, ownQuantity.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_slices_add_up_to_the_bill()
|
||||
{
|
||||
// D-42: the composition of disjoint slices reconciles, because a figure is the exact sum of its lines.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
TypePrice(2, Water, 5, "EUR/m3", D(2025, 2, 1)),
|
||||
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
var always = new ServicePeriod(D(2020, 1, 1));
|
||||
|
||||
var bill = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 300), "L")],
|
||||
standing: [StandingChargeScope.ForEnergyType(Electricity, always)],
|
||||
manual: [Manual(1, D(2025, 2, 1), 100, categoryId: 7)]);
|
||||
|
||||
var slices = new[]
|
||||
{
|
||||
bill.Lines[0].Total,
|
||||
bill.Lines[1].Total,
|
||||
bill.Lines[2].Total,
|
||||
bill.StandingCharges[0].Total,
|
||||
bill.ManualCosts.Total,
|
||||
};
|
||||
var composed = CostAmount.Sum(slices);
|
||||
|
||||
Assert.Equal(bill.Total.Cost, composed.Cost);
|
||||
Assert.Equal(bill.Total.Status, composed.Status);
|
||||
Assert.Equal(bill.Total.MissingPrices, composed.MissingPrices);
|
||||
Assert.Equal(CostStatus.Partial, bill.Total.Status);
|
||||
Assert.True(bill.Total.IncludesNotPriced);
|
||||
Assert.Equal((3 * 30) + (2 * 50) + 36 + 100, bill.Total.Cost!.Value, 9);
|
||||
Assert.Equal(
|
||||
[(TariffComponent.UnitPrice, CostStatus.PriceGap, (int?)WaterMeter), (TariffComponent.UnitPrice, CostStatus.NotPriced, OilTank)],
|
||||
bill.MissingPrices.Select(m => (m.Component, m.Reason, m.MeterId)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Totals;
|
||||
using MeterVault.Core.Domain;
|
||||
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
|
||||
|
||||
namespace MeterVault.Core.Tests.Analysis.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// D-38 and brief §4.3: "not priced" (no tariff at all), "price gap" (a month without a price inside a priced
|
||||
/// history), a free zero tariff, and an unknown quantity are four different answers — and none of them is a
|
||||
/// fabricated zero.
|
||||
/// </summary>
|
||||
public sealed class CostCalculatorCoverageTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_line_without_any_tariff_is_not_priced_and_says_from_when()
|
||||
{
|
||||
var book = Book();
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 400), "L")]);
|
||||
|
||||
Assert.All(result.Lines[0].Buckets, c =>
|
||||
{
|
||||
Assert.Equal(CostStatus.NotPriced, c.Status);
|
||||
Assert.Null(c.Cost);
|
||||
});
|
||||
Assert.Equal(CostStatus.NotPriced, result.Total.Status);
|
||||
Assert.Equal(
|
||||
new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, Oil, OilTank, D(2025, 1, 1), D(2025, 3, 1)),
|
||||
Assert.Single(result.MissingPrices));
|
||||
Assert.False(Assert.Single(result.MissingPrices).IsCredit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_not_priced_line_is_an_attention_item_not_a_reason_for_a_partial_bill()
|
||||
{
|
||||
// D-44: the seeded oil tank has no tariff; the bill is Strom + water, complete for everything priced.
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100)), Line(OilTank, Oil, Each(buckets, 400), "L")]);
|
||||
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
Assert.True(result.Total.IncludesNotPriced);
|
||||
Assert.Equal(60, result.Total.Cost!.Value, 9);
|
||||
Assert.Equal(BucketStatus.Available, result.Total.Availability);
|
||||
Assert.Equal(CostStatus.NotPriced, Assert.Single(result.MissingPrices).Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_month_before_the_first_price_of_a_priced_scope_is_a_gap_and_the_year_is_partial()
|
||||
{
|
||||
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
|
||||
var months = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 4, 30));
|
||||
var year = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 4, 30));
|
||||
|
||||
var monthly = Price(months, book, [Line(WaterMeter, Water, Each(months, 10), "m³")]);
|
||||
var yearly = Price(year, book, [Line(WaterMeter, Water, Each(year, 10), "m³")]);
|
||||
|
||||
Assert.Equal(
|
||||
[CostStatus.PriceGap, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced],
|
||||
monthly.Lines[0].Buckets.Select(c => c.Status));
|
||||
Assert.Null(monthly.Lines[0].Buckets[0].Cost);
|
||||
Assert.Equal(50, monthly.Lines[0].Buckets[2].Cost!.Value, 9);
|
||||
|
||||
var total = yearly.Totals.Single();
|
||||
Assert.Equal(CostStatus.Partial, total.Status);
|
||||
Assert.Equal(100, total.Cost!.Value, 9);
|
||||
Assert.Equal(
|
||||
new MissingPrice(TariffComponent.UnitPrice, CostStatus.PriceGap, TariffScope.EnergyType, Water, WaterMeter, D(2025, 1, 1), D(2025, 2, 1)),
|
||||
Assert.Single(total.MissingPrices));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_price_that_ended_leaves_a_gap_after_it()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2024, 1, 1), D(2025, 1, 31)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
Assert.Equal([CostStatus.Priced, CostStatus.PriceGap, CostStatus.PriceGap], result.Lines[0].Buckets.Select(c => c.Status));
|
||||
var missing = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal((D(2025, 2, 1), D(2025, 3, 1)), (missing.FirstMonth, missing.LastMonth));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_explicit_zero_tariff_is_a_valid_zero()
|
||||
{
|
||||
var book = Book(TypePrice(1, Water, 0, "EUR/m3", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]);
|
||||
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
Assert.Equal(0, result.Total.Cost);
|
||||
Assert.Empty(result.MissingPrices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_known_zero_quantity_needs_no_price()
|
||||
{
|
||||
// A meter not yet installed reads a known zero (D-24): the months before the first tariff are no gap.
|
||||
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(WaterMeter, Water, Monthly(buckets, m => m.Month < 3 ? 0 : 10), "m³")]);
|
||||
|
||||
Assert.All(result.Lines[0].Buckets, c => Assert.Equal(CostStatus.Priced, c.Status));
|
||||
Assert.Equal([0d, 0d, 50d], result.Lines[0].Buckets.Select(c => Math.Round(c.Cost!.Value, 9)));
|
||||
Assert.Empty(result.MissingPrices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_month_without_data_needs_no_price()
|
||||
{
|
||||
// The seeded grid meter starts in September 2022, its first tariff too: January to August of that year have
|
||||
// neither data nor a price, and asking for a tariff there would be noise — the cost is unknown for want of data.
|
||||
var book = Book(TypePrice(1, Electricity, 0.16, "EUR/kWh", D(2022, 9, 1)));
|
||||
var year = Buckets(BucketSize.Year, D(2022, 1, 1), D(2022, 12, 31));
|
||||
|
||||
var result = Price(year, book, [Line(Grid, Electricity, Monthly(year, m => m.Month < 10 ? null : 100))]);
|
||||
|
||||
Assert.Empty(result.MissingPrices);
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
|
||||
Assert.Equal(48, result.Total.Cost!.Value, 9);
|
||||
|
||||
// A month on its own without data is unknown, not a gap.
|
||||
var months = Buckets(BucketSize.Month, D(2022, 1, 1), D(2022, 12, 31));
|
||||
var monthly = Price(months, book, [Line(Grid, Electricity, Monthly(months, m => m.Month < 10 ? null : 100))]);
|
||||
var january = monthly.Lines[0].Buckets[0];
|
||||
Assert.Equal(CostStatus.Priced, january.Status);
|
||||
Assert.Equal(BucketStatus.Missing, january.Availability);
|
||||
Assert.Null(january.Cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_line_without_data_or_tariff_reports_nothing_missing()
|
||||
{
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, Book(), [Line(OilTank, Oil, [.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Unknown(p))], "L")]);
|
||||
|
||||
Assert.Empty(result.MissingPrices);
|
||||
Assert.False(result.Total.IncludesNotPriced);
|
||||
Assert.Null(result.Total.Cost);
|
||||
Assert.Equal(BucketStatus.Missing, result.Total.Availability);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(BucketStatus.Unresolved)]
|
||||
[InlineData(BucketStatus.Pending)]
|
||||
[InlineData(BucketStatus.Invalid)]
|
||||
public void Data_that_cannot_be_cut_still_needs_its_price(BucketStatus status)
|
||||
{
|
||||
// Unresolved, pending or invalid quantities exist — they are only unknown per bucket — so a gap is still a gap.
|
||||
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(WaterMeter, Water, [CostQuantity.Unknown(CostCalculator.Parts(buckets).Single(), status)], "m³")]);
|
||||
|
||||
Assert.Equal(CostStatus.PriceGap, result.Total.Status);
|
||||
Assert.Equal(CostStatus.PriceGap, Assert.Single(result.MissingPrices).Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_quantities_under_no_tariff_do_not_make_a_line_priced()
|
||||
{
|
||||
// Folding the whole line: zero months price nothing, the rest has no tariff — the line is still not priced.
|
||||
var book = Book();
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(OilTank, Oil, Monthly(buckets, m => m.Month < 3 ? 0 : 400), "L")]);
|
||||
|
||||
Assert.Equal(CostStatus.Priced, result.Lines[0].Buckets[0].Status);
|
||||
Assert.Equal(CostStatus.NotPriced, result.Lines[0].Buckets[2].Status);
|
||||
Assert.Equal(CostStatus.NotPriced, result.Lines[0].Total.Status);
|
||||
Assert.Null(result.Lines[0].Total.Cost);
|
||||
Assert.Equal(D(2025, 3, 1), Assert.Single(result.MissingPrices).FirstMonth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_bucket_holding_an_interval_longer_than_a_month_is_priced_whole_at_one_price()
|
||||
{
|
||||
// Review R5 (A-16): a quarterly delta meter leaves every month unresolved; the year holds its intervals whole,
|
||||
// and one price covers every month, so the year costs its quantity at that price.
|
||||
var book = Book(TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
|
||||
var unresolved = CostCalculator.Parts(buckets).Select(p => CostQuantity.Unknown(p, BucketStatus.Unresolved)).ToList();
|
||||
var line = Line(Grid, Electricity, unresolved) with { Spans = [new CostSpan(0, 1200, BucketStatus.Available)] };
|
||||
|
||||
var result = Price(buckets, book, [line]);
|
||||
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
Assert.Equal(BucketStatus.Available, result.Total.Availability);
|
||||
Assert.Equal(120, result.Total.Cost!.Value, 9);
|
||||
Assert.Empty(result.Lines[0].MonthsWithPriceChangeInsideInterval);
|
||||
|
||||
// Without a span, the year stays unknown.
|
||||
var unknown = Price(buckets, book, [Line(Grid, Electricity, unresolved)]);
|
||||
Assert.Null(unknown.Total.Cost);
|
||||
Assert.Equal(BucketStatus.Unresolved, unknown.Total.Availability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_price_change_inside_a_whole_bucket_leaves_it_unknown_and_names_its_months()
|
||||
{
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 1, 1), D(2025, 7, 31)),
|
||||
TypePrice(2, Electricity, 0.20, "EUR/kWh", D(2025, 8, 1)));
|
||||
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
|
||||
var parts = CostCalculator.Parts(buckets);
|
||||
var line = Line(Grid, Electricity, [.. parts.Select(p => CostQuantity.Unknown(p, BucketStatus.Unresolved))])
|
||||
with { Spans = [new CostSpan(0, 1200, BucketStatus.Available)] };
|
||||
|
||||
var result = Price(buckets, book, [line]);
|
||||
|
||||
Assert.Null(result.Total.Cost);
|
||||
Assert.Equal(BucketStatus.Unresolved, result.Total.Availability);
|
||||
Assert.Equal([.. parts.Select(p => p.Month)], result.Lines[0].MonthsWithPriceChangeInsideInterval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_span_prices_only_the_months_with_data_and_leaves_resolved_buckets_alone()
|
||||
{
|
||||
// A tank read from March: January and February have no data and no price, which does not stop the span.
|
||||
var book = Book(TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 3, 1)));
|
||||
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
|
||||
var quantities = CostCalculator.Parts(buckets)
|
||||
.Select(p => p.Month.Month < 3 ? CostQuantity.Unknown(p) : CostQuantity.Unknown(p, BucketStatus.Unresolved))
|
||||
.ToList();
|
||||
var line = Line(Grid, Electricity, quantities) with { Spans = [new CostSpan(0, 500, BucketStatus.Partial)] };
|
||||
|
||||
var result = Price(buckets, book, [line]);
|
||||
|
||||
Assert.Equal(50, result.Total.Cost!.Value, 9);
|
||||
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
|
||||
|
||||
// A bucket whose months are all resolved keeps its month-by-month price, span or not.
|
||||
var months = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
var resolved = Line(Grid, Electricity, Each(months, 10)) with { Spans = [new CostSpan(0, 999, BucketStatus.Available)] };
|
||||
Assert.Equal(1, Price(months, book, [resolved]).Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_quantity_leaves_the_cost_unknown_but_priced()
|
||||
{
|
||||
// Price coverage and quantity availability are separate: March is priced but unmeasured.
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
var quantities = Monthly(buckets, m => m.Month == 3 ? null : 100);
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, quantities)]);
|
||||
|
||||
var march = result.Lines[0].Buckets[2];
|
||||
Assert.Equal(CostStatus.Priced, march.Status);
|
||||
Assert.Equal(BucketStatus.Missing, march.Availability);
|
||||
Assert.Null(march.Cost);
|
||||
|
||||
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
Assert.Equal(60, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(BucketStatus.Unresolved)]
|
||||
[InlineData(BucketStatus.Invalid)]
|
||||
[InlineData(BucketStatus.Pending)]
|
||||
public void A_quantity_that_is_not_a_number_is_never_priced_even_with_an_amount(BucketStatus status)
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
|
||||
var part = CostCalculator.Parts(buckets).Single();
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, [new CostQuantity(part.FirstDay, part.EndDay, 100, status)])]);
|
||||
|
||||
Assert.Null(result.Total.Cost);
|
||||
Assert.Equal(status, result.Total.Availability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_partial_quantity_keeps_its_partial_cost()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
|
||||
var part = CostCalculator.Parts(buckets).Single();
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, [CostQuantity.Known(part, 50, BucketStatus.Partial)])]);
|
||||
|
||||
Assert.Equal(15, result.Total.Cost!.Value, 9);
|
||||
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_part_without_a_quantity_reads_as_missing()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, [CostQuantity.Known(CostCalculator.Parts(buckets)[0], 100)])]);
|
||||
|
||||
Assert.Equal(BucketStatus.Missing, result.Lines[0].Buckets[1].Availability);
|
||||
Assert.Null(result.Lines[0].Buckets[1].Cost);
|
||||
Assert.Equal(30, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
// ---- where the missing price belongs (D-52) -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void A_gap_is_suggested_in_the_scope_that_holds_the_price_history()
|
||||
{
|
||||
// The meter has its own history, so the gap is filled there, not at the type.
|
||||
var book = Book(MeterPrice(1, Grid, 0.30, "EUR/kWh", D(2025, 2, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
var missing = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal((TariffScope.Meter, (int?)Grid), (missing.Scope, missing.ScopeId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_gap_under_a_global_price_is_suggested_globally()
|
||||
{
|
||||
var book = Book(Tariff(1, TariffScope.Global, null, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2025, 2, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
var missing = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal((TariffScope.Global, (int?)null, CostStatus.PriceGap), (missing.Scope, missing.ScopeId, missing.Reason));
|
||||
}
|
||||
|
||||
// ---- D-34: feed-in -------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Feed_in_is_credited_on_the_export_line_only_and_kept_apart_from_the_charges()
|
||||
{
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
FeedInPrice(2, Electricity, 0.08, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 300)), Line(Export, Electricity, Each(buckets, 200), kind: BillLineKind.FeedIn)]);
|
||||
|
||||
var import = result.Lines[0].Total;
|
||||
Assert.Equal(90, import.Usage!.Value, 9);
|
||||
Assert.Null(import.FeedInCredit);
|
||||
|
||||
var export = result.Lines[1].Total;
|
||||
Assert.Equal(16, export.FeedInCredit!.Value, 9);
|
||||
Assert.Null(export.Usage);
|
||||
Assert.Null(export.Charges);
|
||||
Assert.Equal(-16, export.Cost!.Value, 9);
|
||||
|
||||
Assert.Equal(90, result.Total.Charges!.Value, 9);
|
||||
Assert.Equal(16, result.Total.FeedInCredit!.Value, 9);
|
||||
Assert.Equal(74, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_feed_in_price_is_an_optional_credit_reported_only_where_there_is_export()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
|
||||
// No export in January and February, some in March.
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 100)), Line(Export, Electricity, Monthly(buckets, m => m.Month == 3 ? 50 : 0), kind: BillLineKind.FeedIn)]);
|
||||
|
||||
var missing = Assert.Single(result.MissingPrices);
|
||||
Assert.True(missing.IsCredit);
|
||||
Assert.Equal((CostStatus.NotPriced, D(2025, 3, 1)), (missing.Reason, missing.FirstMonth));
|
||||
|
||||
// The bill stays priced: the credit is left out, the charges are complete.
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
Assert.True(result.Total.IncludesNotPriced);
|
||||
Assert.Equal(90, result.Total.Cost!.Value, 9);
|
||||
Assert.Equal(0d, result.Total.FeedInCredit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_feed_in_price_never_credits_a_billed_line()
|
||||
{
|
||||
// Generation and import are not export: only a FeedIn line earns the credit.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
FeedInPrice(2, Electricity, 0.08, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 300))]);
|
||||
|
||||
Assert.Null(result.Total.FeedInCredit);
|
||||
Assert.Equal(90, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_feed_in_price_in_the_wrong_unit_is_a_mismatch_on_the_credit()
|
||||
{
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
FeedInPrice(2, Electricity, 0.08, "EUR/m3", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 300)), Line(Export, Electricity, Each(buckets, 200), kind: BillLineKind.FeedIn)]);
|
||||
|
||||
Assert.Equal(CostStatus.UnitMismatch, result.Lines[1].Total.Status);
|
||||
Assert.Equal(CostStatus.Partial, result.Total.Status);
|
||||
Assert.Equal(90, result.Total.Cost!.Value, 9);
|
||||
var missing = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal((TariffComponent.FeedIn, CostStatus.UnitMismatch, (int?)2), (missing.Component, missing.Reason, missing.TariffId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
|
||||
|
||||
namespace MeterVault.Core.Tests.Analysis.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// D-41: a manual cost is booked in full on its PeriodStart local day, when that day is inside the request and not
|
||||
/// after today; PeriodEnd is informational.
|
||||
/// </summary>
|
||||
public sealed class CostCalculatorManualCostTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_manual_cost_is_booked_in_full_on_its_start_day()
|
||||
{
|
||||
// A yearly oil delivery invoice dated 3 March: the whole amount in March, nothing spread over the year.
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 12, 31));
|
||||
|
||||
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 3, 3), 1200, categoryId: 4, end: D(2025, 12, 31))]);
|
||||
|
||||
var booking = Assert.Single(result.ManualCosts.Bookings);
|
||||
Assert.Equal(new ManualCostBooking(1, 4, null, D(2025, 3, 3), 2, 1200, false), booking);
|
||||
Assert.Equal(1200, result.Totals[2].Manual!.Value, 9);
|
||||
Assert.All(result.Totals.Where((_, i) => i != 2), t => Assert.Null(t.Manual));
|
||||
Assert.Equal(1200, result.Total.Cost!.Value, 9);
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_manual_cost_lands_in_the_week_that_contains_its_day()
|
||||
{
|
||||
// Mon 31 March – Sun 6 April: a cost dated 31 March belongs to that week, not to a March bucket.
|
||||
var weeks = Buckets(BucketSize.Week, D(2025, 3, 24), D(2025, 4, 13));
|
||||
|
||||
var result = Price(weeks, Book(), [], manual: [Manual(1, D(2025, 3, 31), 80)]);
|
||||
|
||||
Assert.Equal(80, result.Totals.At(weeks, D(2025, 3, 31)).Manual!.Value, 9);
|
||||
Assert.Equal(1, Assert.Single(result.ManualCosts.Bookings).BucketIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_manual_cost_dated_after_today_is_not_booked_but_listed()
|
||||
{
|
||||
// The September bucket reaches the end of the month, but today is the 19th.
|
||||
var buckets = new[] { Bucket(D(2025, 9, 1), D(2025, 10, 1)) };
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
Book(),
|
||||
[],
|
||||
today: D(2025, 9, 19),
|
||||
manual: [Manual(1, D(2025, 9, 19), 50), Manual(2, D(2025, 9, 25), 70), Manual(3, D(2025, 8, 31), 90), Manual(4, D(2025, 10, 1), 30)]);
|
||||
|
||||
Assert.Equal([1], result.ManualCosts.Bookings.Select(b => b.ManualCostId));
|
||||
Assert.Equal([2], result.ManualCosts.AfterTodayIds);
|
||||
Assert.Equal(50, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manual_costs_outside_the_request_are_ignored()
|
||||
{
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 2, 28), 10), Manual(2, D(2025, 4, 1), 20)]);
|
||||
|
||||
Assert.Empty(result.ManualCosts.Bookings);
|
||||
Assert.Empty(result.ManualCosts.AfterTodayIds);
|
||||
Assert.Null(result.Total.Cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_manual_cost_only_instance_still_has_a_cost()
|
||||
{
|
||||
// No meter at all: the Heizung manual costs alone make the bill (brief §11, manual-cost-only instance).
|
||||
var buckets = Buckets(BucketSize.Month, D(2026, 1, 1), D(2026, 2, 28));
|
||||
|
||||
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2026, 1, 1), 200), Manual(2, D(2026, 2, 1), 180)]);
|
||||
|
||||
Assert.Equal([200d, 180d], result.Totals.Select(t => t.Cost!.Value));
|
||||
Assert.Equal(BucketStatus.Available, result.Total.Availability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_manual_cost_in_another_currency_is_booked_and_flagged()
|
||||
{
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 3, 5), 40, currency: "USD"), Manual(2, D(2025, 3, 6), 60, currency: "€")]);
|
||||
|
||||
Assert.Equal([true, false], result.ManualCosts.Bookings.Select(b => b.CurrencyMismatch));
|
||||
Assert.True(result.Total.Unverified);
|
||||
Assert.Equal(100, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manual_costs_add_to_a_bill_exactly_once()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))], manual: [Manual(1, D(2025, 2, 10), 25)]);
|
||||
|
||||
Assert.Equal(30 + 30 + 25, result.Total.Cost!.Value, 9);
|
||||
Assert.Equal(25, result.ManualCosts.Total.Manual!.Value, 9);
|
||||
Assert.Equal(55, result.Totals[1].Cost!.Value, 9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Quantities;
|
||||
using MeterVault.Core.Analysis.Totals;
|
||||
using MeterVault.Core.Domain;
|
||||
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
|
||||
|
||||
namespace MeterVault.Core.Tests.Analysis.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// D-36 and D-37: every month is priced with the price in effect on its 15th, whatever the bucket size, and a price
|
||||
/// applies only in a unit that fits the meter's normalized unit.
|
||||
/// </summary>
|
||||
public sealed class CostCalculatorPricingTests
|
||||
{
|
||||
// ---- D-36: the 15th ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void A_month_is_priced_with_the_price_in_effect_on_its_15th()
|
||||
{
|
||||
// 0.30 from January, 0.40 from 16 March, 0.50 from 15 May: March keeps the old price (the change comes a day
|
||||
// too late), May already has the new one (in effect on the 15th itself).
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 3, 16)),
|
||||
TypePrice(3, Electricity, 0.50, "EUR/kWh", D(2025, 5, 15)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 5, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
var costs = result.Lines[0].Buckets.Select(c => c.Cost!.Value).ToList();
|
||||
Assert.Equal([30, 30, 30, 40, 50], costs.Select(c => Math.Round(c, 9)));
|
||||
Assert.All(result.Lines[0].Buckets, c => Assert.Equal(CostStatus.Priced, c.Status));
|
||||
Assert.Equal(180, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_price_ending_on_the_14th_leaves_its_month_to_the_next_price()
|
||||
{
|
||||
// The old price's ValidTo is inclusive but ends before the 15th; the successor starts on the 15th.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1), D(2025, 2, 14)),
|
||||
TypePrice(2, Electricity, 0.35, "EUR/kWh", D(2025, 2, 15)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 2, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
Assert.Equal(35, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_week_straddling_a_month_is_priced_with_each_month_s_own_price()
|
||||
{
|
||||
// Mon 27 Jan – Sun 2 Feb 2025: five January days at 0.30, two February days at 0.40, 10 kWh a day.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 2, 1)));
|
||||
var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2));
|
||||
Assert.Single(buckets);
|
||||
|
||||
var parts = CostCalculator.Parts(buckets);
|
||||
Assert.Equal([(D(2025, 1, 27), D(2025, 2, 1)), (D(2025, 2, 1), D(2025, 2, 3))], parts.Select(p => (p.FirstDay, p.EndDay)));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Daily(buckets, _ => 10))]);
|
||||
|
||||
Assert.Equal((50 * 0.30) + (20 * 0.40), result.Total.Cost!.Value, 9);
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(BucketSize.Day)]
|
||||
[InlineData(BucketSize.Week)]
|
||||
[InlineData(BucketSize.Month)]
|
||||
[InlineData(BucketSize.Year)]
|
||||
public void The_bucket_size_never_changes_the_total(BucketSize size)
|
||||
{
|
||||
// Three price changes (one mid-month), a yearly standing charge and a feed-in credit over 2024 (a leap year):
|
||||
// day, week, month and year buckets must all give the same bill, and each equal the sum of its month buckets.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.31, "EUR/kWh", D(2023, 1, 1)),
|
||||
TypePrice(2, Electricity, 0.28, "EUR/kWh", D(2024, 4, 20)),
|
||||
TypePrice(3, Electricity, 26.5, "ct/kWh", D(2024, 9, 1)),
|
||||
FeedInPrice(4, Electricity, 8.2, "ct/kWh", D(2020, 1, 1)),
|
||||
BasePrice(5, TariffScope.EnergyType, Electricity, 180, "EUR/Jahr", D(2020, 1, 1)));
|
||||
static double Import(DateOnly d) => 5 + (d.DayOfYear % 7);
|
||||
static double Exported(DateOnly d) => d.Month is >= 4 and <= 9 ? 3 + (d.Day % 4) : 0.5;
|
||||
|
||||
CostResult PriceWith(BucketSize bucketSize)
|
||||
{
|
||||
var buckets = Buckets(bucketSize, D(2024, 1, 1), D(2024, 12, 31));
|
||||
return Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Daily(buckets, Import)), Line(Export, Electricity, Daily(buckets, Exported), kind: BillLineKind.FeedIn)],
|
||||
standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))]);
|
||||
}
|
||||
|
||||
var months = PriceWith(BucketSize.Month);
|
||||
var other = PriceWith(size);
|
||||
|
||||
Assert.Equal(months.Total.Cost!.Value, other.Total.Cost!.Value, 6);
|
||||
Assert.Equal(months.Total.Usage!.Value, other.Total.Usage!.Value, 6);
|
||||
Assert.Equal(months.Total.StandingCharge!.Value, other.Total.StandingCharge!.Value, 6);
|
||||
Assert.Equal(180, other.Total.StandingCharge!.Value, 6);
|
||||
Assert.Equal(months.Total.FeedInCredit!.Value, other.Total.FeedInCredit!.Value, 6);
|
||||
Assert.Equal(months.Totals.Sum(t => t.Cost!.Value), other.Totals.Sum(t => t.Cost!.Value), 6);
|
||||
Assert.Equal(CostStatus.Priced, other.Total.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_year_is_the_sum_of_its_months_not_a_year_priced_from_one_sample()
|
||||
{
|
||||
// The old engine priced a year bucket at the 1 July price × the whole year's quantity.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.20, "EUR/kWh", D(2025, 1, 1)),
|
||||
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 7, 1)));
|
||||
var year = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
|
||||
var months = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 12, 31));
|
||||
Assert.Equal(12, CostCalculator.Parts(year).Count);
|
||||
|
||||
var yearly = Price(year, book, [Line(Grid, Electricity, Each(year, 100))]);
|
||||
var monthly = Price(months, book, [Line(Grid, Electricity, Each(months, 100))]);
|
||||
|
||||
Assert.Equal((6 * 100 * 0.20) + (6 * 100 * 0.40), yearly.Total.Cost!.Value, 9);
|
||||
Assert.Equal(monthly.Totals.Sum(t => t.Cost!.Value), yearly.Totals.Single().Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_year_to_date_bucket_ends_in_a_part_of_the_current_month()
|
||||
{
|
||||
var buckets = ToDate(PeriodPreset.YearToDate, AnalysisClock.At(AnalysisClock.Berlin, 2025, 9, 19, 14, 0), BucketSize.Year);
|
||||
|
||||
var parts = CostCalculator.Parts(buckets);
|
||||
|
||||
Assert.Equal(9, parts.Count);
|
||||
Assert.Equal((D(2025, 9, 1), D(2025, 9, 20)), (parts[^1].FirstDay, parts[^1].EndDay));
|
||||
Assert.All(parts, p => Assert.Equal(0, p.BucketIndex));
|
||||
}
|
||||
|
||||
// ---- D-37: units ------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void A_price_in_cents_per_kWh_is_scaled_to_the_currency()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 30, "ct/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
Assert.Equal(30, result.Total.Cost!.Value, 9);
|
||||
Assert.False(result.Total.Unverified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_price_per_MWh_prices_a_kWh_meter()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 250, "EUR/MWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 400))]);
|
||||
|
||||
Assert.Equal(100, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("L", 1000, 950)]
|
||||
[InlineData("m³", 1, 950)]
|
||||
[InlineData("m3", 2, 1900)]
|
||||
public void A_price_per_100_litres_prices_litres_and_cubic_metres(string unit, double quantity, double expected)
|
||||
{
|
||||
var book = Book(TypePrice(1, Oil, 95, "EUR/100L", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, quantity), unit)]);
|
||||
|
||||
Assert.Equal(expected, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_price_whose_unit_does_not_fit_makes_the_cost_unavailable_for_unit()
|
||||
{
|
||||
// A kWh price on a water meter: the old engine charged m³ at the electricity price.
|
||||
var book = Book(Tariff(7, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 12), "m³")]);
|
||||
|
||||
var line = result.Lines[0];
|
||||
Assert.All(line.Buckets, c =>
|
||||
{
|
||||
Assert.Equal(CostStatus.UnitMismatch, c.Status);
|
||||
Assert.Null(c.Cost);
|
||||
});
|
||||
var missing = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal(
|
||||
new MissingPrice(TariffComponent.UnitPrice, CostStatus.UnitMismatch, TariffScope.EnergyType, Water, WaterMeter, D(2025, 3, 1), D(2025, 4, 1), 7, TariffUnitIssue.IncompatibleUnit),
|
||||
missing);
|
||||
Assert.Null(result.Total.Cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_mismatching_meter_price_does_not_fall_back_to_the_type_price()
|
||||
{
|
||||
// The user's own meter price wins by precedence; applying the type's instead would hide the broken override.
|
||||
var book = Book(
|
||||
TypePrice(1, Water, 5, "EUR/m3", D(2025, 1, 1)),
|
||||
MeterPrice(2, WaterMeter, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 12), "m³")]);
|
||||
|
||||
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
|
||||
Assert.Equal(2, Assert.Single(result.MissingPrices).TariffId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_price_in_another_currency_is_a_mismatch()
|
||||
{
|
||||
var book = Book(TypePrice(3, Electricity, 0.25, "USD/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
|
||||
Assert.Equal(TariffUnitIssue.CurrencyMismatch, Assert.Single(result.MissingPrices).Issue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_instance_in_another_currency_prices_its_own_currency()
|
||||
{
|
||||
var book = TariffBook.Create([TypePrice(3, Electricity, 25, "Rp./kWh", D(2025, 1, 1))], "CHF");
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
Assert.Equal("CHF", result.Currency);
|
||||
Assert.Equal(25, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_unit_applies_at_face_value_with_a_warning()
|
||||
{
|
||||
var book = Book(TypePrice(9, Electricity, 0.30, "pauschal", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
|
||||
|
||||
Assert.Equal(60, result.Total.Cost!.Value, 9);
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
Assert.True(result.Total.Unverified);
|
||||
Assert.All(result.Lines[0].Buckets, c => Assert.True(c.Unverified));
|
||||
Assert.Equal(new TariffWarning(9, TariffComponent.UnitPrice, TariffUnitIssue.Unparseable, Grid, D(2025, 3, 1)), Assert.Single(result.Warnings));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_line_is_priced_in_the_unit_it_is_given_not_a_raw_unit()
|
||||
{
|
||||
// A burner converted to litres by a fixed rate is priced per litre; its raw unit (h) would mismatch.
|
||||
var book = Book(Tariff(1, TariffScope.Meter, OilTank, TariffComponent.UnitPrice, 1.10, "EUR/L", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
|
||||
|
||||
var litres = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 100), "L")]);
|
||||
var hours = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 100), "h")]);
|
||||
|
||||
Assert.Equal(110, litres.Total.Cost!.Value, 9);
|
||||
Assert.Equal(CostStatus.UnitMismatch, hours.Total.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Quantities;
|
||||
using MeterVault.Core.Analysis.Totals;
|
||||
using MeterVault.Core.Domain;
|
||||
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
|
||||
|
||||
namespace MeterVault.Core.Tests.Analysis.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// D-40: a standing charge accrues per local day over its scope's service period — once per scope, never once per
|
||||
/// meter with data (the old engine charged a type's base price for every meter of the type, and a whole month for a
|
||||
/// month that had barely begun).
|
||||
/// </summary>
|
||||
public sealed class CostCalculatorStandingChargeTests
|
||||
{
|
||||
private static readonly ServicePeriod Always = new(D(2000, 1, 1));
|
||||
|
||||
[Fact]
|
||||
public void A_type_standing_charge_accrues_once_per_day_however_many_meters_are_billed()
|
||||
{
|
||||
const int second = 13;
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
BasePrice(2, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 100), service: Always), Line(second, Electricity, Each(buckets, 50), service: Always)],
|
||||
standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
var row = Assert.Single(result.StandingCharges);
|
||||
Assert.Equal((TariffScope.EnergyType, (int?)Electricity), (row.Scope, row.ScopeId));
|
||||
Assert.Equal(12, row.Total.StandingCharge!.Value, 9);
|
||||
|
||||
// The meters' own lines carry no share of it.
|
||||
Assert.All(result.Lines, l => Assert.Null(l.Total.StandingCharge));
|
||||
Assert.Equal(45 + 12, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_month_to_date_accrues_the_days_up_to_today_only()
|
||||
{
|
||||
// 19 September at 14:00: nineteen days of a 30-day month, not the whole month.
|
||||
var now = AnalysisClock.At(AnalysisClock.Berlin, 2025, 9, 19, 14, 0);
|
||||
var buckets = ToDate(PeriodPreset.MonthToDate, now, BucketSize.Day);
|
||||
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "EUR/month", D(2025, 1, 1)));
|
||||
|
||||
var result = Price(buckets, book, [], today: D(2025, 9, 19), standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
Assert.Equal(19, result.Totals.Count);
|
||||
Assert.All(result.Totals, t => Assert.Equal(1, t.StandingCharge!.Value, 9));
|
||||
Assert.Equal(19, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void In_a_zone_behind_UTC_the_local_month_and_day_decide()
|
||||
{
|
||||
// 22:00 on 30 September in New York is already 1 October in UTC: month to date is still September, all 30 days.
|
||||
var now = AnalysisClock.At(AnalysisClock.NewYork, 2025, 9, 30, 22, 0);
|
||||
var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, now, AnalysisClock.NewYork);
|
||||
var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets;
|
||||
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "USD/month", D(2025, 1, 1)));
|
||||
|
||||
var result = CostCalculator.Calculate(new CostRequest(
|
||||
buckets,
|
||||
PeriodResolver.LocalDate(now, AnalysisClock.NewYork),
|
||||
TariffBook.Create(book.Tariffs, "USD"),
|
||||
[],
|
||||
[StandingChargeScope.ForEnergyType(Electricity, Always)]));
|
||||
|
||||
Assert.Equal((D(2025, 9, 1), D(2025, 10, 1)), (CostCalculator.Parts(buckets).Single().FirstDay, CostCalculator.Parts(buckets).Single().EndDay));
|
||||
Assert.Equal(30, result.Total.StandingCharge!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_accrues_after_today_even_inside_a_bucket()
|
||||
{
|
||||
var buckets = new[] { Bucket(D(2025, 9, 1), D(2025, 10, 1)) };
|
||||
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "EUR/month", D(2025, 1, 1)));
|
||||
|
||||
var result = Price(buckets, book, [], today: D(2025, 9, 10), standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
Assert.Equal(10, result.Total.StandingCharge!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_meter_standing_charge_accrues_on_its_line_from_install_to_retire()
|
||||
{
|
||||
// Installed 10 March, retired 20 June (inclusive): 22/31 of March, April, May, 20/30 of June, nothing in July.
|
||||
var service = ServicePeriod.ForMeter(D(2025, 3, 10), D(2025, 6, 20), firstDataDay: D(2025, 3, 12));
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
BasePrice(2, TariffScope.Meter, Grid, 10, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 7, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 0), service: service)]);
|
||||
|
||||
var standing = result.Lines[0].Buckets.Select(c => c.StandingCharge!.Value).ToList();
|
||||
Assert.Equal(10 * 22 / 31d, standing[0], 9);
|
||||
Assert.Equal(10, standing[1], 9);
|
||||
Assert.Equal(10, standing[2], 9);
|
||||
Assert.Equal(10 * 20 / 30d, standing[3], 9);
|
||||
Assert.Equal(0, standing[4], 9);
|
||||
Assert.Empty(result.StandingCharges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_service_period_ignores_reading_gaps()
|
||||
{
|
||||
// A meter with a month of missing data still pays its standing charge for that month.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
BasePrice(2, TariffScope.Meter, Grid, 10, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(buckets, book, [Line(Grid, Electricity, Monthly(buckets, m => m.Month == 2 ? null : 100), service: Always)]);
|
||||
|
||||
var february = result.Lines[0].Buckets[1];
|
||||
Assert.Equal(10, february.StandingCharge!.Value, 9);
|
||||
Assert.Null(february.Usage);
|
||||
Assert.Equal(10, february.Cost!.Value, 9);
|
||||
Assert.Equal(BucketStatus.Partial, february.Availability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_yearly_charge_accrues_by_the_days_of_the_year()
|
||||
{
|
||||
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 366, "EUR/Jahr", D(2020, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2024, 1, 1), D(2024, 12, 31));
|
||||
|
||||
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
Assert.Equal(31, result.Totals[0].StandingCharge!.Value, 9);
|
||||
Assert.Equal(29, result.Totals[1].StandingCharge!.Value, 9);
|
||||
Assert.Equal(366, result.Total.StandingCharge!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_quarterly_charge_accrues_by_the_days_of_its_calendar_quarter()
|
||||
{
|
||||
// Q1 2025 has 90 days, Q2 91: 90 EUR per quarter is 1 EUR a day in Q1 and 90/91 in Q2.
|
||||
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 90, "EUR/Quartal", D(2020, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30));
|
||||
|
||||
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
var charges = result.Totals.Select(t => t.StandingCharge!.Value).ToList();
|
||||
Assert.Equal(31, charges[0], 9);
|
||||
Assert.Equal(28, charges[1], 9);
|
||||
Assert.Equal(31, charges[2], 9);
|
||||
Assert.Equal(90 * 30 / 91d, charges[3], 9);
|
||||
Assert.Equal(180, result.Total.StandingCharge!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_daily_charge_is_charged_per_day()
|
||||
{
|
||||
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 0.5, "EUR/Tag", D(2020, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 2, 1), D(2025, 2, 28));
|
||||
|
||||
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
Assert.Equal(14, result.Total.StandingCharge!.Value, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(10, 20)]
|
||||
[InlineData(16, 10)]
|
||||
public void A_standing_charge_price_change_takes_effect_by_the_15th_rule(int changeDay, double marchPrice)
|
||||
{
|
||||
// 10 EUR/month, then 20 from the change day: a change up to the 15th prices all of March at 20 (1–9 March
|
||||
// included), one on the 16th leaves March at 10. April is 20 either way — per day, month by month.
|
||||
var book = Book(
|
||||
BasePrice(1, TariffScope.EnergyType, Electricity, 10, "EUR/Monat", D(2025, 1, 1)),
|
||||
BasePrice(2, TariffScope.EnergyType, Electricity, 20, "EUR/Monat", D(2025, 3, changeDay)));
|
||||
var weeks = Buckets(BucketSize.Week, D(2025, 2, 24), D(2025, 4, 6));
|
||||
|
||||
var result = Price(weeks, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
// Mon 24 February – Sun 2 March: five February days at 10/28, two March days at the March price.
|
||||
var straddling = result.Totals.At(weeks, D(2025, 2, 24));
|
||||
Assert.Equal((5 * 10 / 28d) + (2 * marchPrice / 31d), straddling.StandingCharge!.Value, 9);
|
||||
Assert.Equal((5 * 10 / 28d) + marchPrice + (6 * 20 / 30d), result.Total.StandingCharge!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_global_standing_charge_is_its_own_row()
|
||||
{
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
BasePrice(2, TariffScope.Global, null, 5, "EUR/Monat", D(2025, 1, 1)),
|
||||
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 100), service: Always)],
|
||||
standing: [StandingChargeScope.ForEnergyType(Electricity, Always), StandingChargeScope.Global(Always)]);
|
||||
|
||||
Assert.Equal([TariffScope.EnergyType, TariffScope.Global], result.StandingCharges.Select(r => r.Scope));
|
||||
Assert.Equal(5, result.StandingCharges[1].Total.StandingCharge!.Value, 9);
|
||||
|
||||
// Each scope's charge is its own: the type's does not replace the global one.
|
||||
Assert.Equal(30 + 12 + 5, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_meter_s_own_standing_charge_comes_on_top_of_the_type_s()
|
||||
{
|
||||
// A heat pump's meter fee next to the supply contract's base price.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
BasePrice(2, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)),
|
||||
MeterPrice(3, Heater, 0.22, "EUR/kWh", D(2025, 1, 1)),
|
||||
BasePrice(4, TariffScope.Meter, Heater, 8, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 200), service: Always), Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice, service: Always)],
|
||||
standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
Assert.Equal(8, result.Lines[1].Total.StandingCharge!.Value, 9);
|
||||
Assert.Equal(22 + 8, result.Lines[1].Total.Cost!.Value, 9);
|
||||
Assert.Equal(60 + 22 + 8 + 12, result.Total.Cost!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_meter_fee_on_a_meter_without_a_line_is_its_own_row_on_that_meter()
|
||||
{
|
||||
// Review R3 (A-18): a PV meter's fee, on a bill that prices only the grid import. It accrues as the PV meter's
|
||||
// own row, from its install date, not on some other meter's line and not nowhere.
|
||||
var book = Book(
|
||||
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
|
||||
BasePrice(2, TariffScope.Meter, Export, 2, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
|
||||
|
||||
var result = Price(
|
||||
buckets,
|
||||
book,
|
||||
[Line(Grid, Electricity, Each(buckets, 100), service: Always)],
|
||||
standing: [StandingChargeScope.ForMeter(Export, new ServicePeriod(D(2025, 2, 1)))]);
|
||||
|
||||
var row = Assert.Single(result.StandingCharges);
|
||||
Assert.Equal((TariffScope.Meter, (int?)Export), (row.Scope, row.ScopeId));
|
||||
Assert.Equal([0d, 2d, 2d], row.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
|
||||
Assert.Equal(90 + 4, result.Total.Cost!.Value, 9);
|
||||
|
||||
// A gap in the fee's history names the meter it belongs to.
|
||||
var gap = Price(
|
||||
Buckets(BucketSize.Month, D(2024, 12, 1), D(2024, 12, 31)),
|
||||
book,
|
||||
[],
|
||||
standing: [StandingChargeScope.ForMeter(Export, new ServicePeriod(D(2024, 1, 1)))]);
|
||||
Assert.Equal((TariffScope.Meter, (int?)Export, (int?)Export), (gap.MissingPrices[0].Scope, gap.MissingPrices[0].ScopeId, gap.MissingPrices[0].MeterId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scope_without_a_base_price_adds_no_row()
|
||||
{
|
||||
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always), StandingChargeScope.Global(Always)]);
|
||||
|
||||
Assert.Empty(result.StandingCharges);
|
||||
Assert.Empty(result.MissingPrices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scope_out_of_service_accrues_nothing()
|
||||
{
|
||||
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, null)]);
|
||||
|
||||
Assert.Equal(0, Assert.Single(result.StandingCharges).Total.StandingCharge);
|
||||
Assert.Equal(CostStatus.Priced, result.Total.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_gap_in_a_standing_charge_history_is_unavailable_for_those_months()
|
||||
{
|
||||
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 3, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
var row = Assert.Single(result.StandingCharges);
|
||||
Assert.Equal([CostStatus.PriceGap, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced], row.Buckets.Select(c => c.Status));
|
||||
Assert.Equal(CostStatus.Partial, row.Total.Status);
|
||||
Assert.Equal(24, row.Total.StandingCharge!.Value, 9);
|
||||
Assert.Equal(
|
||||
new MissingPrice(TariffComponent.BasePrice, CostStatus.PriceGap, TariffScope.EnergyType, Electricity, null, D(2025, 1, 1), D(2025, 2, 1)),
|
||||
Assert.Single(result.MissingPrices));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_standing_charge_unit_is_taken_per_month_with_a_warning()
|
||||
{
|
||||
var book = Book(BasePrice(4, TariffScope.EnergyType, Electricity, 12, "pauschal", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
Assert.Equal(12, result.Total.StandingCharge!.Value, 9);
|
||||
Assert.True(result.Total.Unverified);
|
||||
Assert.Equal(TariffUnitIssue.Unparseable, Assert.Single(result.Warnings).Issue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_standing_charge_in_an_unsupported_period_is_a_mismatch()
|
||||
{
|
||||
var book = Book(BasePrice(4, TariffScope.EnergyType, Electricity, 24, "EUR/2 Monate", D(2025, 1, 1)));
|
||||
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
|
||||
|
||||
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
|
||||
|
||||
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
|
||||
var missing = Assert.Single(result.MissingPrices);
|
||||
Assert.Equal((4, TariffUnitIssue.UnsupportedPeriod), (missing.TariffId, missing.Issue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_scope_s_service_period_spans_its_meters()
|
||||
{
|
||||
// A retired grid meter and its successor: the type is in service from the first install to now.
|
||||
var retired = ServicePeriod.ForMeter(D(2020, 5, 1), D(2023, 6, 30), null);
|
||||
var successor = ServicePeriod.ForMeter(null, null, D(2023, 7, 3));
|
||||
var neverInService = ServicePeriod.ForMeter(null, null, null);
|
||||
|
||||
var span = ServicePeriod.Span([retired, successor, neverInService]);
|
||||
|
||||
Assert.Null(neverInService);
|
||||
Assert.Equal(D(2020, 5, 1), span!.FirstDay);
|
||||
Assert.Null(span.LastDay);
|
||||
Assert.Equal(D(2023, 6, 30), ServicePeriod.Span([retired])!.LastDay);
|
||||
Assert.Null(ServicePeriod.Span([]));
|
||||
Assert.Null(ServicePeriod.ForMeter(D(2024, 1, 1), D(2023, 1, 1), null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Quantities;
|
||||
using MeterVault.Core.Costing;
|
||||
using MeterVault.Core.Domain;
|
||||
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
|
||||
|
||||
namespace MeterVault.Core.Tests.Analysis.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// The tariff book resolves exactly like <see cref="TariffResolver"/> (meter over type over global, latest start within
|
||||
/// a scope, both ends inclusive) — but deterministically on ties — and answers the D-35/D-38 questions.
|
||||
/// </summary>
|
||||
public sealed class CostingTariffBookTests
|
||||
{
|
||||
private static readonly Tariff[] History =
|
||||
[
|
||||
Tariff(1, TariffScope.Global, null, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2023, 1, 1)),
|
||||
Tariff(2, TariffScope.Global, 99, TariffComponent.UnitPrice, 0.31, "EUR/kWh", D(2023, 6, 1)),
|
||||
TypePrice(3, Electricity, 0.40, "EUR/kWh", D(2023, 3, 1), D(2023, 12, 31)),
|
||||
TypePrice(4, Electricity, 0.42, "EUR/kWh", D(2023, 9, 1), D(2023, 10, 31)),
|
||||
MeterPrice(5, Grid, 0.50, "EUR/kWh", D(2023, 5, 1), D(2023, 5, 31)),
|
||||
TypePrice(6, Water, 5, "EUR/m3", D(2023, 1, 1)),
|
||||
Tariff(7, TariffScope.EnergyType, Electricity, TariffComponent.BasePrice, 12, "EUR/Monat", D(2023, 1, 1)),
|
||||
];
|
||||
|
||||
public static TheoryData<int, int, int, int> Dates => new()
|
||||
{
|
||||
{ Grid, Electricity, 2, 15 },
|
||||
{ Grid, Electricity, 4, 15 },
|
||||
{ Grid, Electricity, 5, 15 },
|
||||
{ Grid, Electricity, 5, 31 },
|
||||
{ Grid, Electricity, 6, 1 },
|
||||
{ Grid, Electricity, 9, 15 },
|
||||
{ Grid, Electricity, 11, 1 },
|
||||
{ Grid, Electricity, 12, 31 },
|
||||
{ WaterMeter, Water, 7, 15 },
|
||||
{ 77, 5, 7, 15 },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Dates))]
|
||||
public void Resolves_like_the_tariff_resolver(int meterId, int energyTypeId, int month, int day)
|
||||
{
|
||||
var book = Book(History);
|
||||
var date = D(2023, month, day);
|
||||
|
||||
foreach (var component in new[] { TariffComponent.UnitPrice, TariffComponent.BasePrice, TariffComponent.FeedIn })
|
||||
{
|
||||
var expected = TariffResolver.Resolve(History, component, meterId, energyTypeId, date);
|
||||
Assert.Same(expected, book.Resolve(component, meterId, energyTypeId, date));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_newer_price_in_the_same_scope_overrides_an_older_one_that_still_covers_the_date()
|
||||
{
|
||||
var book = Book(History);
|
||||
|
||||
Assert.Equal(4, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2023, 9, 15))!.Id);
|
||||
Assert.Equal(3, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2023, 11, 15))!.Id);
|
||||
Assert.Equal(2, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2024, 1, 15))!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tie_on_scope_and_start_goes_to_the_later_entry_whatever_the_load_order()
|
||||
{
|
||||
var older = TypePrice(10, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1));
|
||||
var newer = TypePrice(11, Electricity, 0.35, "EUR/kWh", D(2025, 1, 1));
|
||||
|
||||
Assert.Same(newer, Book(older, newer).Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2025, 3, 15)));
|
||||
Assert.Same(newer, Book(newer, older).Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2025, 3, 15)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolving_in_one_scope_never_falls_back_to_another()
|
||||
{
|
||||
var book = Book(History);
|
||||
|
||||
Assert.Equal(7, book.ResolveInScope(TariffComponent.BasePrice, TariffScope.EnergyType, Electricity, D(2024, 1, 15))!.Id);
|
||||
Assert.Null(book.ResolveInScope(TariffComponent.BasePrice, TariffScope.Global, null, D(2024, 1, 15)));
|
||||
Assert.Null(book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Meter, Grid, D(2023, 6, 15)));
|
||||
Assert.Equal(2, book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Global, 12345, D(2024, 1, 15))!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scope_is_priced_when_it_has_a_tariff_at_any_date()
|
||||
{
|
||||
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2030, 1, 1)));
|
||||
|
||||
Assert.True(book.HasAny(TariffComponent.UnitPrice, WaterMeter, Water));
|
||||
Assert.False(book.HasAny(TariffComponent.UnitPrice, Grid, Electricity));
|
||||
Assert.False(book.HasAny(TariffComponent.FeedIn, WaterMeter, Water));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tariff_that_ends_before_it_starts_is_left_out()
|
||||
{
|
||||
var broken = TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1), D(2025, 1, 31));
|
||||
|
||||
var book = Book(broken);
|
||||
|
||||
Assert.Empty(book.Tariffs);
|
||||
Assert.False(book.HasAny(TariffComponent.UnitPrice, WaterMeter, Water));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_own_price_is_known_per_month_by_the_15th()
|
||||
{
|
||||
var book = Book(MeterPrice(1, Heater, 0.22, "EUR/kWh", D(2025, 3, 16)), TypePrice(2, Electricity, 0.30, "EUR/kWh", D(2020, 1, 1)));
|
||||
|
||||
Assert.True(book.HasMeterScopedUnitPrice(Heater));
|
||||
Assert.False(book.HasMeterScopedUnitPrice(Grid));
|
||||
Assert.False(book.HasOwnUnitPrice(Heater, D(2025, 3, 1)));
|
||||
Assert.True(book.HasOwnUnitPrice(Heater, D(2025, 4, 30)));
|
||||
Assert.False(book.HasOwnUnitPrice(Grid, D(2025, 4, 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Units_are_checked_against_the_instance_currency()
|
||||
{
|
||||
var eur = TypePrice(1, Electricity, 30, "ct/kWh", D(2025, 1, 1));
|
||||
var chf = TypePrice(2, Electricity, 0.3, "CHF/kWh", D(2025, 1, 1));
|
||||
var book = TariffBook.Create([eur, chf], "€");
|
||||
|
||||
Assert.Equal(0.3, book.Applicability(eur, "kWh").Convert(30), 12);
|
||||
Assert.Equal(TariffUnitIssue.CurrencyMismatch, book.Applicability(chf, "kWh").Issue);
|
||||
Assert.Equal(TariffUnitIssue.IncompatibleUnit, book.Applicability(eur, "m³").Issue);
|
||||
Assert.Same(book.UnitOf(eur), book.UnitOf(eur));
|
||||
Assert.Equal(BillingPeriod.Month, book.Accrual(Tariff(3, TariffScope.Global, null, TariffComponent.BasePrice, 5, "EUR/Monat", D(2025, 1, 1))).Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_price_date_is_the_15th_of_the_month()
|
||||
{
|
||||
Assert.Equal(D(2024, 2, 15), TariffBook.PriceDate(D(2024, 2, 29)));
|
||||
Assert.Equal(D(2025, 12, 15), TariffBook.PriceDate(D(2025, 12, 1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Totals;
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Core.Tests.Analysis.Costing;
|
||||
|
||||
/// <summary>
|
||||
/// Builders for the cost-calculator tests: real buckets from the period resolver and bucket planner (Berlin), tariffs,
|
||||
/// and quantities per part. Nothing here reads the wall clock.
|
||||
/// </summary>
|
||||
internal static class CostingTestData
|
||||
{
|
||||
public const int Electricity = 1;
|
||||
public const int Water = 2;
|
||||
public const int Oil = 3;
|
||||
|
||||
public const int Grid = 10;
|
||||
public const int Heater = 11;
|
||||
public const int Export = 12;
|
||||
public const int WaterMeter = 20;
|
||||
public const int OilTank = 30;
|
||||
|
||||
/// <summary>A "now" long after every range the tests price, so custom ranges resolve as complete.</summary>
|
||||
public static readonly DateTimeOffset FarFuture = AnalysisClock.Utc(2040, 1, 1);
|
||||
|
||||
public static DateOnly D(int year, int month, int day) => new(year, month, day);
|
||||
|
||||
/// <summary>The buckets of the complete range <paramref name="first"/> – <paramref name="last"/> (inclusive), as the planner cuts them.</summary>
|
||||
public static IReadOnlyList<AnalysisBucket> Buckets(BucketSize size, DateOnly first, DateOnly last)
|
||||
{
|
||||
var period = PeriodResolver.Resolve(PeriodPreset.Custom, first, last, FarFuture, AnalysisClock.Berlin);
|
||||
return Plan(period, size);
|
||||
}
|
||||
|
||||
/// <summary>The buckets of a to-date preset at <paramref name="now"/> (Berlin).</summary>
|
||||
public static IReadOnlyList<AnalysisBucket> ToDate(PeriodPreset preset, DateTimeOffset now, BucketSize size) =>
|
||||
Plan(PeriodResolver.Resolve(preset, null, null, now, AnalysisClock.Berlin), size);
|
||||
|
||||
/// <summary>A bucket spelled out, for the cases a planner never produces (a bucket reaching past today).</summary>
|
||||
public static AnalysisBucket Bucket(DateOnly first, DateOnly end, BucketSize size = BucketSize.Month) =>
|
||||
new(first, end, AnalysisClock.At(AnalysisClock.Berlin, first.Year, first.Month, first.Day),
|
||||
AnalysisClock.At(AnalysisClock.Berlin, end.Year, end.Month, end.Day), size);
|
||||
|
||||
public static Tariff Tariff(
|
||||
int id,
|
||||
TariffScope scope,
|
||||
int? scopeId,
|
||||
TariffComponent component,
|
||||
double value,
|
||||
string unit,
|
||||
DateOnly from,
|
||||
DateOnly? to = null) => new()
|
||||
{
|
||||
Id = id,
|
||||
ScopeType = scope,
|
||||
ScopeId = scopeId,
|
||||
Component = component,
|
||||
Value = value,
|
||||
Unit = unit,
|
||||
ValidFrom = from,
|
||||
ValidTo = to,
|
||||
};
|
||||
|
||||
/// <summary>A unit price of an energy type.</summary>
|
||||
public static Tariff TypePrice(int id, int energyTypeId, double value, string unit, DateOnly from, DateOnly? to = null) =>
|
||||
Tariff(id, TariffScope.EnergyType, energyTypeId, TariffComponent.UnitPrice, value, unit, from, to);
|
||||
|
||||
/// <summary>A meter's own unit price.</summary>
|
||||
public static Tariff MeterPrice(int id, int meterId, double value, string unit, DateOnly from, DateOnly? to = null) =>
|
||||
Tariff(id, TariffScope.Meter, meterId, TariffComponent.UnitPrice, value, unit, from, to);
|
||||
|
||||
public static Tariff BasePrice(int id, TariffScope scope, int? scopeId, double value, string unit, DateOnly from, DateOnly? to = null) =>
|
||||
Tariff(id, scope, scopeId, TariffComponent.BasePrice, value, unit, from, to);
|
||||
|
||||
public static Tariff FeedInPrice(int id, int energyTypeId, double value, string unit, DateOnly from) =>
|
||||
Tariff(id, TariffScope.EnergyType, energyTypeId, TariffComponent.FeedIn, value, unit, from);
|
||||
|
||||
public static TariffBook Book(params Tariff[] tariffs) => TariffBook.Create(tariffs, "EUR");
|
||||
|
||||
/// <summary>Every part's quantity from a per-day amount: the sum over its days.</summary>
|
||||
public static List<CostQuantity> Daily(IReadOnlyList<AnalysisBucket> buckets, Func<DateOnly, double> perDay) =>
|
||||
[.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, DaysOf(p).Sum(perDay)))];
|
||||
|
||||
/// <summary>Every part's quantity from an amount per whole local month, prorated by days when a part is shorter.</summary>
|
||||
public static List<CostQuantity> Monthly(IReadOnlyList<AnalysisBucket> buckets, Func<DateOnly, double?> perMonth) =>
|
||||
[.. CostCalculator.Parts(buckets).Select(p => perMonth(p.Month) is { } amount
|
||||
? CostQuantity.Known(p, amount * p.Days / DateTime.DaysInMonth(p.Month.Year, p.Month.Month))
|
||||
: CostQuantity.Unknown(p))];
|
||||
|
||||
/// <summary>Every part the same amount.</summary>
|
||||
public static List<CostQuantity> Each(IReadOnlyList<AnalysisBucket> buckets, double amount) =>
|
||||
[.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, amount))];
|
||||
|
||||
public static CostLine Line(
|
||||
int meterId,
|
||||
int energyTypeId,
|
||||
IReadOnlyList<CostQuantity> quantities,
|
||||
string unit = "kWh",
|
||||
BillLineKind kind = BillLineKind.UnitPrice,
|
||||
ServicePeriod? service = null) =>
|
||||
new(meterId, energyTypeId, kind, unit, quantities, service);
|
||||
|
||||
public static CostResult Price(
|
||||
IReadOnlyList<AnalysisBucket> buckets,
|
||||
TariffBook book,
|
||||
IEnumerable<CostLine> lines,
|
||||
DateOnly? today = null,
|
||||
IEnumerable<StandingChargeScope>? standing = null,
|
||||
IEnumerable<ManualCost>? manual = null) =>
|
||||
CostCalculator.Calculate(new CostRequest(
|
||||
buckets,
|
||||
today ?? D(2039, 12, 31),
|
||||
book,
|
||||
[.. lines],
|
||||
standing is null ? null : [.. standing],
|
||||
manual is null ? null : [.. manual]));
|
||||
|
||||
public static ManualCost Manual(int id, DateOnly start, double amount, int? categoryId = null, int? meterId = null, DateOnly? end = null, string currency = "EUR") => new()
|
||||
{
|
||||
Id = id,
|
||||
CategoryId = categoryId,
|
||||
MeterId = meterId,
|
||||
PeriodStart = start,
|
||||
PeriodEnd = end ?? start,
|
||||
Amount = amount,
|
||||
Currency = currency,
|
||||
};
|
||||
|
||||
/// <summary>The cost of the bucket starting on <paramref name="first"/>.</summary>
|
||||
public static CostAmount At(this IReadOnlyList<CostAmount> cells, IReadOnlyList<AnalysisBucket> buckets, DateOnly first)
|
||||
{
|
||||
for (var i = 0; i < buckets.Count; i++)
|
||||
{
|
||||
if (buckets[i].FirstDay == first)
|
||||
{
|
||||
return cells[i];
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"No bucket starts on {first:yyyy-MM-dd}.");
|
||||
}
|
||||
|
||||
private static IEnumerable<DateOnly> DaysOf(CostPart part) =>
|
||||
Enumerable.Range(0, part.Days).Select(part.FirstDay.AddDays);
|
||||
|
||||
private static IReadOnlyList<AnalysisBucket> Plan(ResolvedPeriod period, BucketSize size)
|
||||
{
|
||||
var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000);
|
||||
Assert.False(plan.Refused);
|
||||
return plan.Buckets;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user