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);
}
}