Files
MeterVault/tests/Core.Tests/Analysis/TotalsPolicyTests.cs
T
Florian Schmidt 8940ef25c3
ci / build-test (push) Successful in 2m31s
Analysis: one selected period, one set of numbers, on every page
The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
2026-09-20 10:29:13 +02:00

941 lines
45 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<TotalsMeter> 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<TotalsMeter> 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<TotalsMeter> 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<TotalsMeter> 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<TotalsMeter> 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<TotalsMeter> 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<ArgumentException>(() => TotalsPolicy.Validate(Meters(), Links(), 404, TotalsOverride.Always));
Assert.Throws<ArgumentException>(() => 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<TotalsOverride>())
{
Assert.Equal(value, TotalsOverrideTokens.Parse(TotalsOverrideTokens.ToToken(value)));
}
}
[Fact]
public void Measures_group_units_by_their_canonical_spelling()
{
List<TotalsMeter> 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<TotalsLink> 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<TotalsMeter> 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<TotalsMeter> 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<TotalsMeter> 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<TotalsLink> 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<MeasureGroup>
{
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<MeterTotalsEntry>
{
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;
}
}