using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis;
///
/// The per-type totals policy (D-22, D-23, D-34, D-35, D-53). The seeded topology is the golden case: summing
/// every meter there counts Haus, Netz and Auto on top of each other, and the sheet bills Netz alone — so these
/// tests pin that the policy finds exactly one non-overlapping meter per measure, bills the grid import, and
/// refuses any override that would count the same energy twice.
///
public sealed class TotalsPolicyTests
{
[Fact]
public void The_seeded_topology_classifies_every_meter_as_the_note_pins()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal(MeterTotalsReason.TotalLoadRole, result.For(Haus).Reason);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal(Haus, result.For(Auto).ParentId);
Assert.Equal(MeterTotalsReason.ContainedByLink, result.For(Auto).Reason);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar1).Class);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar2).Class);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(SummeSolar).Class);
Assert.Equal(MeterTotalsReason.VirtualView, result.For(SummeSolar).Reason);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
Assert.Equal(MeterTotalsReason.ConsumptionRoot, result.For(Wasser).Reason);
Assert.Equal(MeterTotalsClass.Use, result.For(Oeltank).Class);
Assert.Equal(MeterTotalsClass.Runtime, result.For(Brenner).Class);
}
[Fact]
public void Seeded_measures_are_one_set_per_type_and_unit()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Equal(
[
new MeasureGroup(TotalsMeasure.Use, "kWh", [Haus]),
new MeasureGroup(TotalsMeasure.GridImport, "kWh", [Netz]),
new MeasureGroup(TotalsMeasure.Generation, "kWh", [Solar1, Solar2]),
],
result.ForType(Electricity).Measures,
MeasureGroupComparer.Instance);
Assert.Equal([new MeasureGroup(TotalsMeasure.Use, "m³", [Wasser])], result.ForType(Water).Measures, MeasureGroupComparer.Instance);
Assert.Equal(
[
new MeasureGroup(TotalsMeasure.Use, "L", [Oeltank]),
new MeasureGroup(TotalsMeasure.Runtime, "h", [Brenner]),
],
result.ForType(Oil).Measures,
MeasureGroupComparer.Instance);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Export));
}
[Fact]
public void The_seeded_bill_is_the_grid_import_for_electricity_and_household_use_elsewhere()
{
var result = TotalsPolicy.Classify(Meters(), Links());
var electricity = result.ForType(Electricity).Billing;
Assert.Equal(BillingBasis.GridImport, electricity.Basis);
Assert.Equal([Netz], electricity.BilledMeterIds);
Assert.Empty(electricity.FeedInMeterIds);
Assert.Empty(electricity.SeparatelyBilled);
Assert.Equal(BillingBasis.Use, result.ForType(Water).Billing.Basis);
Assert.Equal([Wasser], result.ForType(Water).Billing.BilledMeterIds);
Assert.Equal([Oeltank], result.ForType(Oil).Billing.BilledMeterIds);
Assert.Equal([Netz, Wasser, Oeltank], result.BillItems.Order());
Assert.False(result.IsBilled(Solar1));
Assert.False(result.IsBilled(Brenner));
}
[Fact]
public void The_seeded_configuration_raises_no_problem_and_no_overlap_hint()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Empty(result.Problems);
Assert.Empty(result.Hints);
Assert.Empty(TotalsPolicy.PossibleOverlapHints(Meters(), Links()));
}
[Fact]
public void A_link_out_of_a_supply_meter_never_makes_its_target_a_subsection()
{
// Netz → Haus and Summe Solar → Haus say what feeds the house, not that the house is part of them.
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Empty(result.For(Haus).ParentIds);
Assert.Empty(result.For(SummeSolar).ParentIds);
}
[Fact]
public void Lifecycle_dates_move_no_meter_where_no_role_passes_between_meters()
{
var plain = TotalsPolicy.Classify(Meters(), Links());
var dated = TotalsPolicy.Classify(
[.. Meters().Select(m => m.Id switch
{
Auto => m with { RetiredAt = new DateOnly(2024, 6, 30) },
Solar2 => m with { InstalledAt = new DateOnly(2025, 3, 1) },
Netz => m with { InstalledAt = new DateOnly(2022, 9, 1), RetiredAt = new DateOnly(2023, 1, 31) },
_ => m,
})],
Links());
foreach (var id in plain.Meters.Keys)
{
Assert.Equal(plain.For(id).Class, dated.For(id).Class);
Assert.Equal(plain.For(id).Measure, dated.For(id).Measure);
}
Assert.Equal(plain.BillItems.Order(), dated.BillItems.Order());
}
[Fact]
public void The_result_does_not_depend_on_the_order_meters_and_links_arrive_in()
{
var forward = TotalsPolicy.Classify(Meters(), Links());
var backward = TotalsPolicy.Classify(Enumerable.Reverse(Meters()), Enumerable.Reverse(Links()));
foreach (var id in forward.Meters.Keys)
{
Assert.Equal(forward.For(id), backward.For(id), EntryComparer.Instance);
}
}
[Fact]
public void A_bidirectional_meter_without_total_load_bills_the_import_credits_the_export_and_leaves_use_unmeasured()
{
// Import 1.8.0, export 2.8.0 and PV, no house meter: none of them is household use — the import is supply,
// the export is never consumption, and generation is not use. Use stays empty rather than guessed; the bill
// is the import and the feed-in credit is earned by the export register only (never by generation).
List meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport),
Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"),
];
var totals = TotalsPolicy.Classify(meters, [new(22, 20)]).ForType(Electricity);
Assert.Empty(totals.MetersIn(TotalsMeasure.Use));
Assert.Equal([20], totals.MetersIn(TotalsMeasure.GridImport));
Assert.Equal([21], totals.MetersIn(TotalsMeasure.Export));
Assert.Equal([22], totals.MetersIn(TotalsMeasure.Generation));
Assert.Equal(BillingBasis.GridImport, totals.Billing.Basis);
Assert.Equal([20], totals.Billing.BilledMeterIds);
Assert.Equal([21], totals.Billing.FeedInMeterIds);
}
[Fact]
public void A_virtual_meter_never_holds_a_role_so_a_virtual_total_load_stays_an_analysis_view()
{
// A-07: a virtual meter is a view over other meters. Counting a stored total_load on it would put a calculation
// into household use (and bill it where no grid import exists), which D-39 allows only through Always.
List meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport),
Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"),
Virtual(23, "Household", Electricity, "kWh", QuantityKind.Net, [20, 21, 22], pureSum: false, MeterRoles.TotalLoad),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Null(meters[3].Role);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(23).Class);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([20], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Empty(result.Problems);
}
[Fact]
public void A_virtual_total_load_in_a_type_without_a_grid_import_is_never_billed()
{
// The probed case: the role made the view household use, and with no grid import that meant billing it.
var meters = Meters();
meters.Add(Virtual(22, "Wasser view", Water, "m³", QuantityKind.Consumption, [Wasser], pureSum: true, MeterRoles.TotalLoad));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(22).Class);
Assert.Equal([Wasser], result.ForType(Water).Billing.BilledMeterIds);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
}
[Fact]
public void A_role_handed_directly_to_a_virtual_meter_is_ignored_and_reported()
{
// A caller that skips MeterRoleRules.Effective still cannot make a view hold a role.
var meters = Meters();
meters.Add(Virtual(23, "House view", Electricity, "kWh", QuantityKind.Consumption, [Haus], pureSum: true) with { Role = MeterRole.TotalLoad });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Contains(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, 23, Role: MeterRole.TotalLoad), result.Problems);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(23).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
}
[Theory]
[InlineData("GRID_IMPORT")]
[InlineData(" Grid_Import ")]
[InlineData("grid_import")]
public void A_role_token_counts_whatever_its_case_and_surrounding_spaces(string token)
{
var result = TotalsPolicy.Classify(
MetersWith(Netz, m => Physical(m.Id, m.Name, m.EnergyTypeId, m.Mode, m.Unit, token)),
Links());
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Empty(result.Problems);
}
[Theory]
[InlineData(MeterRoles.GridImport)]
[InlineData(MeterRoles.GridExport)]
[InlineData(MeterRoles.TotalLoad)]
public void A_role_stored_on_a_tank_plays_no_part_so_the_tank_is_billed_as_household_use(string token)
{
// A tank measures a store, not a flow (MeterRoleRules): a stored grid_import must not bill it as the grid,
// and a stored grid_export must not earn it a feed-in credit.
var result = TotalsPolicy.Classify(
MetersWith(Oeltank, m => Physical(m.Id, m.Name, m.EnergyTypeId, m.Mode, m.Unit, token)),
Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Oeltank).Class);
Assert.Equal(BillingBasis.Use, result.ForType(Oil).Billing.Basis);
Assert.Equal([Oeltank], result.ForType(Oil).Billing.BilledMeterIds);
Assert.Empty(result.ForType(Oil).Billing.FeedInMeterIds);
}
[Fact]
public void A_meter_reporting_export_is_never_consumption_even_without_the_role()
{
List meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", kind: QuantityKind.Export),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal(MeterTotalsClass.Export, result.For(21).Class);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([21], result.ForType(Electricity).Billing.FeedInMeterIds);
}
[Fact]
public void Always_on_Summe_Solar_replaces_both_solar_meters_in_generation()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([SummeSolar], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(SummeSolar).Class);
Assert.Equal(TotalsMeasure.Generation, result.For(SummeSolar).Measure);
Assert.Equal([Solar1, Solar2], result.For(SummeSolar).ReplacesIds);
foreach (var solar in new[] { Solar1, Solar2 })
{
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(solar).Class);
Assert.Equal(MeterTotalsReason.CoveredByOverride, result.For(solar).Reason);
Assert.Equal(SummeSolar, result.For(solar).RelatedMeterId);
Assert.Equal(MeterTotalsClass.Generation, result.For(solar).Natural);
}
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), SummeSolar, TotalsOverride.Always).IsAllowed);
}
[Fact]
public void Always_on_Auto_while_Haus_is_counted_is_refused_naming_Haus()
{
var check = TotalsPolicy.Validate(Meters(), Links(), Auto, TotalsOverride.Always);
Assert.False(check.IsAllowed);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void A_stored_always_that_overlaps_is_not_applied_and_is_reported()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Auto, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), result.For(Auto).RefusedOverride);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Contains(new TotalsProblem(TotalsProblemKind.OverrideRefused, Auto, Haus, Conflict: TotalsConflictReason.OverlapsCountedMeter), result.Problems);
}
[Fact]
public void Always_on_Auto_is_allowed_once_Haus_is_set_to_never()
{
var neverHaus = MetersWithOverride((Haus, TotalsOverride.Never));
Assert.True(TotalsPolicy.Validate(neverHaus, Links(), Auto, TotalsOverride.Always).IsAllowed);
var result = TotalsPolicy.Classify(MetersWithOverride((Haus, TotalsOverride.Never), (Auto, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Haus).Class);
Assert.Equal(MeterTotalsReason.OverrideNever, result.For(Haus).Reason);
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(Auto).Class);
Assert.Empty(result.For(Auto).ReplacesIds);
Assert.Equal([Auto], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
}
[Fact]
public void Putting_Haus_back_to_auto_is_refused_while_Autos_always_depends_on_its_absence()
{
var meters = MetersWithOverride((Haus, TotalsOverride.Never), (Auto, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), Haus, TotalsOverride.Auto);
Assert.Equal(new TotalsConflict(TotalsConflictReason.DisplacesOverride, Auto), check.Conflict);
}
[Fact]
public void Always_on_a_solar_meter_that_Summe_Solar_replaces_is_refused_naming_Summe_Solar()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), Solar1, TotalsOverride.Always);
Assert.False(check.IsAllowed);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, SummeSolar), check.Conflict);
}
[Fact]
public void Always_on_a_virtual_that_depends_on_an_always_meter_is_refused_naming_it()
{
var meters = MetersWithOverride((Solar1, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), SummeSolar, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Solar1), check.Conflict);
}
[Fact]
public void Two_stored_always_sums_over_the_same_sources_keep_the_lower_id_and_report_the_other()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
meters.Add(Virtual(10, "PV total (copy)", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar2], pureSum: true, totals: TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([SummeSolar], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(10).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, SummeSolar), result.For(10).RefusedOverride);
Assert.Contains(result.Problems, p => p.Kind == TotalsProblemKind.OverrideRefused && p.MeterId == 10);
}
[Fact]
public void Always_on_a_virtual_over_a_subsection_is_refused_naming_its_counted_ancestor()
{
var meters = Meters();
meters.Add(Virtual(10, "Car only", Electricity, "kWh", QuantityKind.Consumption, [Auto], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void Always_on_a_virtual_whose_sources_nest_is_refused_because_its_sum_double_counts()
{
var meters = Meters();
meters.Add(Virtual(10, "House plus car", Electricity, "kWh", QuantityKind.Consumption, [Haus, Auto], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, Haus), check.Conflict);
}
[Fact]
public void Always_on_a_formula_that_is_not_a_pure_sum_is_refused()
{
var meters = Meters();
meters.Add(Virtual(10, "Netz Einsparung", Electricity, "kWh", QuantityKind.Consumption, [Haus, Netz], pureSum: false));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAPureSum, null), check.Conflict);
}
[Theory]
[InlineData(QuantityKind.Net)]
[InlineData(QuantityKind.Indicator)]
public void Always_on_a_net_or_indicator_view_is_refused_as_not_additive(QuantityKind kind)
{
var meters = Meters();
meters.Add(Virtual(10, "View", Electricity, "kWh", kind, [Haus, Netz], pureSum: false));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAdditive, null), check.Conflict);
}
[Fact]
public void A_virtual_without_a_usable_definition_cannot_be_counted()
{
var meters = Meters();
meters.Add(Virtual(10, "Needs configuration", Electricity, "kWh", QuantityKind.Consumption, [], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(TotalsConflictReason.NotAPureSum, check.Conflict!.Reason);
}
[Fact]
public void Always_on_a_consumption_sum_of_roots_replaces_them_in_use_and_in_the_bill()
{
List meters =
[
Physical(30, "House water", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden water", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(32, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal([32], result.ForType(Water).MetersIn(TotalsMeasure.Use));
Assert.Equal([30, 31], result.For(32).ReplacesIds);
Assert.Equal(BillingBasis.Use, result.ForType(Water).Billing.Basis);
Assert.Equal([32], result.ForType(Water).Billing.BilledMeterIds);
}
[Fact]
public void Always_on_a_meter_that_already_counts_changes_nothing()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Haus, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Null(result.For(Haus).RefusedOverride);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), Haus, TotalsOverride.Always).IsAllowed);
}
[Fact]
public void Never_on_the_grid_import_makes_the_type_bill_household_use()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Netz, TotalsOverride.Never)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Natural);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.GridImport));
Assert.Equal(BillingBasis.Use, result.ForType(Electricity).Billing.Basis);
Assert.Equal([Haus], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), Netz, TotalsOverride.Never).IsAllowed);
}
[Fact]
public void Never_keeps_a_subsection_s_parent_so_the_page_can_still_explain_it()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Auto, TotalsOverride.Never)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Auto).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Natural);
Assert.Equal([Haus], result.For(Auto).ParentIds);
}
[Fact]
public void A_duplicated_role_is_kept_by_the_lowest_id_and_the_other_meter_is_reported()
{
var meters = Meters();
meters.Add(Physical(10, "Second grid meter", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Equal(MeterTotalsClass.NotCounted, result.For(10).Class);
Assert.Equal(MeterTotalsReason.DuplicateRole, result.For(10).Reason);
Assert.Equal(Netz, result.For(10).RelatedMeterId);
Assert.Contains(new TotalsProblem(TotalsProblemKind.DuplicateRole, 10, Netz, MeterRole.GridImport), result.Problems);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
// The loser still measures the grid: its link into Haus is a supply edge, and it never joins household use.
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal(TotalsConflictReason.RoleConflict, TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always).Conflict!.Reason);
}
[Fact]
public void The_same_role_in_different_energy_types_is_no_conflict()
{
var meters = Meters();
meters.Add(Physical(10, "Main water", Water, MeterMode.CumulativeCounter, "m³", MeterRoles.TotalLoad));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Wasser)]);
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
Assert.Equal([10], result.ForType(Water).MetersIn(TotalsMeasure.Use));
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Wasser).Class);
}
[Fact]
public void A_role_handed_to_a_mode_that_cannot_hold_it_is_ignored_and_reported()
{
var meters = MetersWith(Solar1, m => m with { Role = MeterRole.TotalLoad });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Contains(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, Solar1, Role: MeterRole.TotalLoad), result.Problems);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar1).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void An_unlinked_consumption_meter_is_taken_as_part_of_the_total_load_and_hinted()
{
var meters = Meters();
meters.Add(Physical(10, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsClass.Breakdown, result.For(10).Class);
Assert.Equal(MeterTotalsReason.AssumedInsideTotalLoad, result.For(10).Reason);
Assert.Equal(Haus, result.For(10).ParentId);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Contains(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, Electricity, 10, Haus), result.Hints);
// Counting it as well would add a part of Haus on top of Haus.
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void A_grid_import_not_linked_to_the_total_load_raises_a_possible_overlap_hint()
{
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).ToList();
var result = TotalsPolicy.Classify(Meters(), links);
Assert.Equal([new OverlapHint(OverlapHintKind.GridImportNotLinkedToTotalLoad, Electricity, Netz, Haus)], result.Hints);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void A_grid_import_reaching_the_total_load_through_another_meter_is_linked()
{
var meters = Meters();
meters.Add(Physical(10, "Sub-distribution", Electricity, MeterMode.CumulativeCounter, "kWh"));
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).Append(new(Netz, 10)).Append(new(10, Haus)).ToList();
var result = TotalsPolicy.Classify(meters, links);
Assert.DoesNotContain(result.Hints, h => h.Kind == OverlapHintKind.GridImportNotLinkedToTotalLoad);
}
[Fact]
public void A_generation_meter_below_another_generation_meter_is_a_breakdown_not_a_second_root()
{
var meters = Meters();
meters.Add(Physical(10, "Inverter", Electricity, MeterMode.GenerationCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Solar1)]);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Solar1).Class);
Assert.Equal(10, result.For(Solar1).ParentId);
Assert.Equal([Solar2, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Runtime_meters_all_count_even_when_linked_below_a_tank()
{
var result = TotalsPolicy.Classify(Meters(), [.. Links(), new(Oeltank, Brenner)]);
Assert.Equal(MeterTotalsClass.Runtime, result.For(Brenner).Class);
Assert.Equal([Oeltank], result.ForType(Oil).MetersIn(TotalsMeasure.Use));
Assert.Equal([Brenner], result.ForType(Oil).MetersIn(TotalsMeasure.Runtime));
}
[Fact]
public void Measures_never_add_across_units()
{
List meters =
[
Physical(30, "Main", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Cistern", Water, MeterMode.DirectDelta, "L"),
];
var groups = TotalsPolicy.Classify(meters, []).ForType(Water).GroupsOf(TotalsMeasure.Use);
Assert.Equal(
[new MeasureGroup(TotalsMeasure.Use, "L", [31]), new MeasureGroup(TotalsMeasure.Use, "m³", [30])],
groups,
MeasureGroupComparer.Instance);
}
[Fact]
public void Links_across_energy_types_are_ignored()
{
var result = TotalsPolicy.Classify(Meters(), [.. Links(), new(Haus, Wasser)]);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
Assert.Empty(result.For(Wasser).ParentIds);
}
[Fact]
public void A_containment_cycle_is_reported_and_neither_meter_becomes_a_root()
{
List meters =
[
Physical(30, "A", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "B", Water, MeterMode.CumulativeCounter, "m³"),
];
var result = TotalsPolicy.Classify(meters, [new(30, 31), new(31, 30)]);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(30).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(31).Class);
Assert.Contains(new TotalsProblem(TotalsProblemKind.ContainmentCycle, 30), result.Problems);
Assert.Contains(new TotalsProblem(TotalsProblemKind.ContainmentCycle, 31), result.Problems);
Assert.Equal(BillingBasis.None, result.ForType(Water).Billing.Basis);
}
[Fact]
public void A_total_load_linked_below_another_consumption_meter_is_reported_and_the_parent_is_not_counted()
{
var meters = Meters();
meters.Add(Physical(10, "Main breaker", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Contains(new TotalsProblem(TotalsProblemKind.TotalLoadIsContained, Haus, 10), result.Problems);
Assert.Equal(MeterTotalsClass.NotCounted, result.For(10).Class);
Assert.Equal(MeterTotalsReason.ContainsTotalLoad, result.For(10).Reason);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void A_type_with_only_generation_bills_nothing()
{
var result = TotalsPolicy.Classify([Physical(40, "Balcony PV", Electricity, MeterMode.GenerationCounter, "kWh")], []);
Assert.Equal(BillingBasis.None, result.ForType(Electricity).Billing.Basis);
Assert.Equal([40], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Empty(result.BillItems);
var unknown = result.ForType(99);
Assert.Empty(unknown.Measures);
Assert.Equal(BillingBasis.None, unknown.Billing.Basis);
}
[Fact]
public void Validate_needs_a_known_meter_and_classify_rejects_duplicate_ids()
{
Assert.Throws(() => TotalsPolicy.Validate(Meters(), Links(), 404, TotalsOverride.Always));
Assert.Throws(() => TotalsPolicy.Classify([.. Meters(), Meters()[0]], Links()));
}
[Theory]
[InlineData(null, TotalsOverride.Auto)]
[InlineData("", TotalsOverride.Auto)]
[InlineData("auto", TotalsOverride.Auto)]
[InlineData("always", TotalsOverride.Always)]
[InlineData(" Never ", TotalsOverride.Never)]
[InlineData("sometimes", TotalsOverride.Auto)]
public void Override_tokens_parse_leniently_and_default_to_auto(string? token, TotalsOverride expected) =>
Assert.Equal(expected, TotalsOverrideTokens.Parse(token));
[Fact]
public void Override_is_read_from_meter_meta_and_round_trips_its_token()
{
Assert.Equal(TotalsOverride.Always, TotalsOverrideTokens.FromMeta("""{"role":"total_load","totals":"always"}"""));
Assert.Equal(TotalsOverride.Auto, TotalsOverrideTokens.FromMeta("not json"));
Assert.Equal(TotalsOverride.Auto, TotalsOverrideTokens.FromMeta("""{"totals":1}"""));
foreach (var value in Enum.GetValues())
{
Assert.Equal(value, TotalsOverrideTokens.Parse(TotalsOverrideTokens.ToToken(value)));
}
}
[Fact]
public void Measures_group_units_by_their_canonical_spelling()
{
List meters =
[
Physical(30, "Main", Water, MeterMode.CumulativeCounter, "m3"),
Physical(31, "Well", Water, MeterMode.CumulativeCounter, "m³"),
];
var groups = TotalsPolicy.Classify(meters, []).ForType(Water).GroupsOf(TotalsMeasure.Use);
Assert.Equal([new MeasureGroup(TotalsMeasure.Use, "m³", [30, 31])], groups, MeasureGroupComparer.Instance);
}
[Theory]
[InlineData(Netz)]
[InlineData(Solar1)]
public void Always_on_a_consumption_sum_over_a_supply_meter_is_refused_naming_it(int source)
{
// m(Netz) as consumption would add the grid's energy to household use, which Haus already holds; m(Solar 1)
// as consumption would add generation to use.
var meters = Meters();
meters.Add(Virtual(10, "Grid as use", Electricity, "kWh", QuantityKind.Consumption, [source], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourceInOtherMeasure, source), check.Conflict);
meters[^1] = meters[^1] with { Override = TotalsOverride.Always };
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Always_on_a_virtual_without_a_declared_result_kind_is_not_additive()
{
// A legacy Summe Solar whose definition is not usable yet: its kind falls back to consumption in D-20, but that
// is a display assumption — counting it would add PV generation to household use.
var meters = MetersWith(SummeSolar, m => m with { Kind = QuantityKind.Consumption, VirtualResultKind = null, Override = TotalsOverride.Always });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAdditive, null), result.For(SummeSolar).RefusedOverride);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(
TotalsConflictReason.NotAdditive,
TotalsPolicy.Validate(MetersWith(SummeSolar, m => m with { VirtualResultKind = null }), Links(), SummeSolar, TotalsOverride.Always).Conflict!.Reason);
}
[Fact]
public void An_always_beside_a_counted_total_load_is_refused_naming_the_total_load()
{
// Main breaker → Haus and Main breaker → Workshop: the workshop is no subsection of Haus by link, but Haus is
// the total load — everything used is already in it, so counting the workshop as well double-counts.
var meters = Meters();
meters.Add(Physical(10, "Main breaker", Electricity, MeterMode.CumulativeCounter, "kWh"));
meters.Add(Physical(11, "Workshop", Electricity, MeterMode.CumulativeCounter, "kWh"));
List links = [.. Links(), new(10, Haus), new(10, 11)];
var check = TotalsPolicy.Validate(meters, links, 11, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void An_always_sum_over_meters_retired_before_the_total_load_was_installed_is_allowed()
{
// Nothing was inside the total load before it existed, so a sum over the older meters adds nothing twice.
var meters = MetersWith(Haus, m => m with { InstalledAt = new DateOnly(2020, 1, 1) });
meters.Add(Physical(10, "Old house", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Physical(11, "Old annex", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Virtual(12, "Old total", Electricity, "kWh", QuantityKind.Consumption, [10, 11], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 12, TotalsOverride.Always);
Assert.True(check.IsAllowed);
}
[Fact]
public void Always_on_a_sum_listing_a_meter_twice_is_refused_as_overlapping()
{
var meters = Meters();
meters.Add(Virtual(10, "Solar 1 twice", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar1], pureSum: true, totals: TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, Solar1), result.For(10).RefusedOverride);
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Always_on_nested_sums_sharing_a_meter_is_refused_as_overlapping()
{
// A = m30 + m31, B = m31 + m32, C = A + B: the expansion keeps multiplicity, so m31 appears twice.
List meters =
[
Physical(30, "House", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden", Water, MeterMode.CumulativeCounter, "m³"),
Physical(32, "Pool", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "C", Water, "m³", QuantityKind.Consumption, [30, 31, 31, 32], pureSum: true),
];
var check = TotalsPolicy.Validate(meters, [], 40, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, 31), check.Conflict);
}
[Fact]
public void Always_on_a_sum_with_a_source_in_another_energy_type_is_refused_naming_it()
{
// Counted in electricity as an m³ group, while water still counts and bills the same meter.
List meters =
[
Physical(30, "Water", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "Water in electricity", Electricity, "m³", QuantityKind.Consumption, [30], pureSum: true, totals: TotalsOverride.Always),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourceInOtherEnergyType, 30), result.For(40).RefusedOverride);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([30], result.ForType(Water).Billing.BilledMeterIds);
}
[Fact]
public void Never_is_always_allowed_even_when_it_lets_a_lower_id_always_push_another_out()
{
// Water 30 and 31, meter 3 below 30 set to Always, and 40 = 30 + 31 set to Always. Today 3 is refused (30
// counts) and 40 replaces 30 and 31. Excluding 30 lets 3 apply first, which then overlaps 40. Excluding a meter
// never counts anything twice, so the save goes through and the displaced override is reported instead.
List meters =
[
Physical(3, "Kitchen", Water, MeterMode.CumulativeCounter, "m³", totals: TotalsOverride.Always),
Physical(30, "House", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always),
];
List links = [new(30, 3)];
var check = TotalsPolicy.Validate(meters, links, 30, TotalsOverride.Never);
Assert.True(check.IsAllowed);
var result = TotalsPolicy.Classify([.. meters.Select(m => m.Id == 30 ? m with { Override = TotalsOverride.Never } : m)], links);
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(3).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, 3), result.For(40).RefusedOverride);
Assert.Contains(new TotalsProblem(TotalsProblemKind.OverrideRefused, 40, 3, Conflict: TotalsConflictReason.OverlapsCountedMeter), result.Problems);
}
[Fact]
public void A_retired_grid_meter_keeps_its_role_beside_its_replacement_and_both_are_billed()
{
// The grid meter was replaced by a new meter record on 31 Jan 2023. Both hold grid_import (A-07); each counts
// only within its own service period, so the bill before the replacement is not lost and the old meter's link
// into Haus stays a supply edge instead of turning Haus into a contained total load.
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 1, 31) });
meters.Add(Physical(10, "Zähler Netz (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 31)));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Empty(result.Problems);
Assert.Empty(result.Hints);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.GridImport, result.For(10).Class);
Assert.Equal([Netz, 10], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Empty(result.For(Haus).ParentIds);
}
[Fact]
public void Grid_meters_in_service_at_the_same_time_are_duplicates_whatever_their_dates()
{
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 3, 31) });
meters.Add(Physical(10, "Second grid meter", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 1)));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsReason.DuplicateRole, result.For(10).Reason);
Assert.Contains(new TotalsProblem(TotalsProblemKind.DuplicateRole, 10, Netz, MeterRole.GridImport), result.Problems);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
}
[Fact]
public void An_unlinked_meter_from_before_the_total_load_was_installed_is_a_root_of_its_own()
{
// The house meter only exists since 2020; the old workshop meter was retired before, so no total load ever
// measured it and it counts in household use for its own period. A meter in service with the house meter is
// still taken to be inside it.
var meters = MetersWith(Haus, m => m with { InstalledAt = new DateOnly(2020, 1, 1) });
meters.Add(Physical(10, "Old workshop", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Physical(11, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsReason.ConsumptionRoot, result.For(10).Reason);
Assert.Equal(MeterTotalsReason.AssumedInsideTotalLoad, result.For(11).Reason);
Assert.Equal([Haus, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Hints, h => h.MeterId == 10);
}
[Fact]
public void An_unlinked_meter_spanning_a_total_load_replacement_is_assumed_inside_both()
{
var meters = MetersWith(Haus, m => m with { RetiredAt = new DateOnly(2023, 1, 31) });
meters.Add(Physical(10, "Zähler Haus (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad, installedAt: new DateOnly(2023, 1, 31)));
meters.Add(Physical(11, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Netz, 10)]);
Assert.Equal([Haus, 10], result.For(11).ParentIds);
Assert.Equal(Haus, result.For(11).RelatedMeterId);
Assert.Equal([Haus, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), TotalsPolicy.Validate(meters, [.. Links(), new(Netz, 10)], 11, TotalsOverride.Always).Conflict);
}
[Fact]
public void A_grid_meter_and_a_total_load_never_in_service_together_raise_no_overlap_hint()
{
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2019, 12, 31) });
meters = [.. meters.Select(m => m.Id == Haus ? m with { InstalledAt = new DateOnly(2020, 1, 1) } : m)];
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).ToList();
var result = TotalsPolicy.Classify(meters, links);
Assert.DoesNotContain(result.Hints, h => h.Kind == OverlapHintKind.GridImportNotLinkedToTotalLoad);
}
private sealed class MeasureGroupComparer : IEqualityComparer
{
public static readonly MeasureGroupComparer Instance = new();
public bool Equals(MeasureGroup? x, MeasureGroup? y) =>
x is not null && y is not null && x.Measure == y.Measure && x.Unit == y.Unit && x.MeterIds.SequenceEqual(y.MeterIds);
public int GetHashCode(MeasureGroup obj) => HashCode.Combine(obj.Measure, obj.Unit);
}
private sealed class EntryComparer : IEqualityComparer
{
public static readonly EntryComparer Instance = new();
public bool Equals(MeterTotalsEntry? x, MeterTotalsEntry? y) =>
x is not null && y is not null
&& (x.MeterId, x.EnergyTypeId, x.Class, x.Reason, x.Natural, x.Measure, x.RelatedMeterId)
== (y.MeterId, y.EnergyTypeId, y.Class, y.Reason, y.Natural, y.Measure, y.RelatedMeterId)
&& Equals(x.RefusedOverride, y.RefusedOverride)
&& x.ParentIds.SequenceEqual(y.ParentIds)
&& x.ReplacesIds.SequenceEqual(y.ReplacesIds);
public int GetHashCode(MeterTotalsEntry obj) => obj.MeterId;
}
}