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