using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Domain; using static MeterVault.Core.Tests.Analysis.TotalsSeed; namespace MeterVault.Core.Tests.Analysis; /// /// D-35: a heat-pump meter in cascade is a subsection of the house for quantities, but the supplier bills it at /// its own tariff. Main 300 kWh at 0.30 and heat pump 100 kWh at 0.22 must bill 200 × 0.30 + 100 × 0.22 — so the /// policy has to say which meter is priced separately and which billed quantity it comes out of, without moving /// anything in the quantity totals. /// public sealed class SeparatelyBilledSubmeterTests { private const int HeatPump = 10; [Fact] public void A_heat_pump_below_Haus_with_its_own_price_is_billed_separately_out_of_the_grid_import() { var meters = Meters(); meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], id => id == HeatPump); var billing = result.ForType(Electricity).Billing; Assert.Equal([Netz], billing.BilledMeterIds); Assert.Equal([new SeparatelyBilledMeter(HeatPump, Netz, Haus)], billing.SeparatelyBilled); Assert.True(result.IsBilled(HeatPump)); // The priceable form: Netz minus the heat pump at the normal price, the heat pump at its own. Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(Netz)!.Deductions); Assert.Equal(BillLineKind.UnitPrice, result.LineOf(Netz)!.Kind); Assert.Equal(BillLineKind.OwnPrice, result.LineOf(HeatPump)!.Kind); Assert.Empty(result.LineOf(HeatPump)!.Deductions); Assert.Null(result.LineOf(Haus)); // Quantities do not move: the heat pump is still a breakdown of household use. Assert.Equal(MeterTotalsClass.Breakdown, result.For(HeatPump).Class); Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); } [Fact] public void Without_its_own_price_a_subsection_stays_inside_its_parent_s_bill() { var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Haus); Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); Assert.False(result.IsBilled(Auto)); } [Fact] public void A_billed_meter_with_its_own_price_is_simply_billed_not_billed_twice() { var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Netz || id == Wasser); Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); Assert.Empty(result.ForType(Water).Billing.SeparatelyBilled); } [Fact] public void Where_household_use_is_billed_the_quantity_comes_out_of_the_parent_directly() { var meters = Meters(); meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "m³")); var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20)], id => id == 20); Assert.Equal([new SeparatelyBilledMeter(20, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled); } [Fact] public void A_cascade_of_separately_billed_meters_subtracts_each_from_the_one_directly_above() { var meters = Meters(); meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "m³")); meters.Add(Physical(21, "Pool", Water, MeterMode.CumulativeCounter, "m³")); var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)], id => id is 20 or 21); Assert.Equal( [new SeparatelyBilledMeter(20, Wasser, null), new SeparatelyBilledMeter(21, 20, null)], result.ForType(Water).Billing.SeparatelyBilled); Assert.Equal([new BillDeduction(20, 1)], result.LineOf(Wasser)!.Deductions); Assert.Equal([new BillDeduction(21, 1)], result.LineOf(20)!.Deductions); } [Fact] public void An_unlinked_meter_assumed_inside_the_total_load_is_not_billed_separately_and_its_price_is_reported_unused() { // Being inside the total load is only an assumption about an unlinked meter; it must not take energy out of // the grid bill. The price names the gap, and the hint tells the user to link the meter. var meters = Meters(); meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); var result = TotalsPolicy.Classify(meters, Links(), id => id == HeatPump); Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); Assert.Empty(result.LineOf(Netz)!.Deductions); Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, HeatPump), result.Problems); Assert.Contains(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, Electricity, HeatPump, Haus), result.Hints); } [Fact] public void A_subsection_set_to_never_is_not_billed_at_all() { var meters = Meters(); meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh", totals: TotalsOverride.Never)); var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], id => id == HeatPump); Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, HeatPump), result.Problems); } [Fact] public void A_consumption_root_with_its_own_price_is_not_a_subsection_and_is_not_billed_separately() { // No link and no total_load: nothing establishes that the root sits behind the grid import, so D-35 (which is // about containment children) does not apply. Link it or give the type a total_load meter to bill it. List meters = [ Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), Physical(21, "Heat pump", Electricity, MeterMode.CumulativeCounter, "kWh"), ]; var result = TotalsPolicy.Classify(meters, [], id => id == 21); Assert.Equal(MeterTotalsClass.Use, result.For(21).Class); Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 21), result.Problems); } [Fact] public void A_priced_consumer_linked_directly_below_the_grid_meter_is_billed_at_its_own_price() { // Review R6 (D-35, Kaskade): grid meter, and a heat pump behind it with its own price, linked grid → pump and // no house meter. The link out of a supply meter is a supply edge (D-22), so the pump is a use root, not a // containment child — yet the grid meter measured it. It is billed at its own price and taken out of the grid // meter that links to it; the quantity measures do not change. List meters = [ Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), Physical(21, "Heat pump", Electricity, MeterMode.CumulativeCounter, "kWh"), Physical(22, "Unlinked", Electricity, MeterMode.CumulativeCounter, "kWh"), ]; var result = TotalsPolicy.Classify(meters, [new(20, 21)], id => id is 21 or 22); Assert.Equal(MeterTotalsClass.Use, result.For(21).Class); var billing = result.ForType(Electricity).Billing; Assert.Equal([new SeparatelyBilledMeter(21, 20, null)], billing.SeparatelyBilled); Assert.Contains(billing.Lines, l => l is { MeterId: 20, Kind: BillLineKind.UnitPrice } && l.Deductions.Single().MeterId == 21); Assert.Contains(billing.Lines, l => l is { MeterId: 21, Kind: BillLineKind.OwnPrice }); Assert.DoesNotContain(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 21), result.Problems); // A priced meter nothing links is still reported: nothing says the grid meter measured it. Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 22), result.Problems); } [Fact] public void A_meter_price_on_a_meter_the_bill_never_prices_is_reported() { // Haus is not billed where the grid import is, so a price scoped to it applies to nothing. var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Haus); Assert.Equal([new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, Haus)], result.Problems); } [Fact] public void A_subsection_below_meters_an_always_sum_replaced_comes_out_of_the_sum() { // 32 = 30 + 31 is billed in place of 30 and 31, and 33 below 30 has its own price: its quantity must come out // of 32, or the bill charges it twice. List meters = [ Physical(30, "House water", Water, MeterMode.CumulativeCounter, "m³"), Physical(31, "Barn water", Water, MeterMode.CumulativeCounter, "m³"), Virtual(32, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always), Physical(33, "Garden", Water, MeterMode.CumulativeCounter, "m³"), ]; var result = TotalsPolicy.Classify(meters, [new(30, 33)], id => id == 33); var billing = result.ForType(Water).Billing; Assert.Equal([32], billing.BilledMeterIds); Assert.Equal([new SeparatelyBilledMeter(33, 32, 30)], billing.SeparatelyBilled); Assert.Equal([new BillDeduction(33, 1)], result.LineOf(32)!.Deductions); } [Fact] public void A_subsection_in_a_convertible_unit_is_deducted_with_its_conversion_factor() { var meters = Meters(); meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "L")); var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20)], id => id == 20); Assert.Equal([new SeparatelyBilledMeter(20, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled); Assert.Equal([new BillDeduction(20, 0.001)], result.LineOf(Wasser)!.Deductions); } [Fact] public void A_subsection_whose_unit_cannot_convert_stays_in_its_parent_s_bill_and_is_reported() { // An instant-rate sensor integrating to kWh cannot come out of an m³ line. It stays inside the water bill at // the water price; the pool below it, in m³, then comes out of the next billed meter up. var meters = Meters(); meters.Add(Physical(20, "Heat meter", Water, MeterMode.InstantRate, "kWh")); meters.Add(Physical(21, "Pool", Water, MeterMode.CumulativeCounter, "m³")); var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)], id => id is 20 or 21); Assert.Equal([new SeparatelyBilledMeter(21, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled); Assert.Equal([new BillDeduction(21, 1)], result.LineOf(Wasser)!.Deductions); Assert.Null(result.LineOf(20)); Assert.Equal([new TotalsProblem(TotalsProblemKind.SeparateBillingUnitMismatch, 20, Wasser)], result.Problems); } [Fact] public void A_subsection_spanning_a_grid_meter_replacement_comes_out_of_each_grid_meter_in_service_with_it() { var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 1, 31) }); meters.Add(Physical(11, "Zähler Netz (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 31))); meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); var result = TotalsPolicy.Classify(meters, [.. Links(), new(11, Haus), new(Haus, HeatPump)], id => id == HeatPump); Assert.Equal( [new SeparatelyBilledMeter(HeatPump, Netz, Haus), new SeparatelyBilledMeter(HeatPump, 11, Haus)], result.ForType(Electricity).Billing.SeparatelyBilled); Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(Netz)!.Deductions); Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(11)!.Deductions); Assert.Single(result.ForType(Electricity).Billing.Lines, l => l.MeterId == HeatPump); } [Fact] public void The_price_predicate_is_asked_once_per_meter_while_classifying_and_never_afterwards() { // A reader's predicate closes over a scoped DbContext; category covers computed later must not call into it. var meters = Meters(); meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); var calls = 0; var disposed = false; bool HasPrice(int id) { ObjectDisposedException.ThrowIf(disposed, typeof(SeparatelyBilledSubmeterTests)); calls++; return id == HeatPump; } var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], HasPrice); disposed = true; var cover = CategoryCover.Compute(result, 101, [Haus, Netz, HeatPump]); Assert.Equal(meters.Count, calls); Assert.Equal([new SeparatelyBilledMeter(HeatPump, Netz, Haus)], cover.SeparatelyBilled); } }